From c768c41641711fabf859923c1a1e221bafc35370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAnjali?= <“anjali.cspatidar@gmail.com”> Date: Sat, 2 May 2026 00:02:10 -0400 Subject: [PATCH 1/7] finally got to it --- .env.example | 27 ++++++ README.md | 149 +++++++++++++++++------------- docker/docker-compose.yml | 1 - package.json | 8 +- scripts/migrate.js | 11 ++- src/app.js | 127 +++++++++++++++---------- src/config/database.js | 23 ++--- src/config/queue.js | 8 +- src/config/redis.js | 73 +++++++++++---- src/jobs/scrapeEvents.js | 83 ++++++----------- src/middleware/cache.js | 33 ++++--- src/middleware/rateLimit.js | 42 ++++++--- src/models/Event.js | 2 +- src/routes/events.js | 14 ++- src/routes/health.js | 25 ++--- src/scrapers/Base/BaseScraper.js | 59 +++++++++--- src/scrapers/eventbrite.js | 44 +++++++-- src/scrapers/eventbriteScraper.js | 38 -------- src/services/scrapingService.js | 6 ++ 19 files changed, 458 insertions(+), 315 deletions(-) create mode 100644 .env.example delete mode 100644 src/scrapers/eventbriteScraper.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..555e2c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +PORT=3000 +NODE_ENV=development +LOG_LEVEL=info + +# PostgreSQL (defaults match docker/docker-compose.yml) +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_NAME=events_db +DB_USER=postgres +DB_PASSWORD=postgres + +# Redis — omit REDIS_ENABLED or set true; set REDIS_ENABLED=false to skip Redis entirely +REDIS_ENABLED= +REDIS_URL= +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 + +# POST /api/v1/events/scrape +API_KEY=dev-secret-change-me + +# Rate limits +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# Worker: optional recurring scrape (ms), minimum 60000; leave unset to disable +SCRAPE_INTERVAL_MS= +SCRAPE_JOB_CONCURRENCY=1 diff --git a/README.md b/README.md index e805672..5ef08ad 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,99 @@ # Eventflow API -> **Status:** This project is actively under development. - -### Event Aggregation and API Gateway - -Eventflow API is a backend service that aggregates event data from multiple public sources, normalizes it into a consistent format, and exposes it through a single RESTAPI. -This project demonstrates web scraping, data normalization, and API design. +Backend service that aggregates event data from multiple public sources, normalizes it, and exposes it through a REST API. ## Features -- Scrapes events from public event pages -- Normalizes messy source data into a consistent schema -- Exposes events through a REST API -- Source-agnostic scraper architecture -- Basic error handling +- Scrapes events from public listing pages (Eventbrite HTML + Meetup via Puppeteer) +- Normalizes source-specific rows into a shared schema +- Persists events in PostgreSQL with upsert by `external_id` +- REST API with optional Redis-backed caching and rate limiting +- Background scraping worker (Bull + Redis) ## Tech Stack -- Node.js -- Express -- Axios -- Cheerio -- PostgreSQL (planned) -- Redis (planned) - -## Project Structure +- Node.js, Express +- PostgreSQL (`pg`) +- Redis (cache, rate limits, Bull queue) +- Axios, Cheerio, Puppeteer + +## Project layout ``` src/ -├── app.js -├── server.js -├── routes/ -│ └── events.js -├── scrapers/ -│ └── eventbrite.js -├── db/ -└── utils/ +├── app.js # Express app factory & HTTP server +├── config/ # database, redis, Bull queue +├── jobs/ # worker job definitions (scrape queue) +├── middleware/ # auth, cache, errors, rate limit +├── routes/ # events, health +├── scrapers/ # per-source scrapers + BaseScraper +├── services/ # event + scraping orchestration +├── utils/ +migrations/ # SQL migrations +docker/docker-compose.yml # Postgres + Redis for local dev ``` -## API Endpoints - -### GET `/events` -Returns a list of normalized events. - -**Example response:** -```json -[ - { - "title": "Intro to Web Development", - "date": "2025-01-20T18:00:00.000Z", - "location": "Toronto, ON", - "price": "free", - "source": "eventbrite" - } -] -``` +## Setup -## Setup & Run Locally -``` -git clone https://github.com/your-username/atlas-api-gateway -cd atlas-api-gateway -npm install -node src/server.js -``` -API runs at: http://localhost:3000 +1. **Dependencies** + ```bash + npm install + ``` + +2. **Infrastructure** (PostgreSQL + Redis) + ```bash + npm run docker:up + ``` + +3. **Environment** + ```bash + cp .env.example .env + # Edit secrets (especially API_KEY) for non-local use. + ``` -## Design Decisions -- Single API gateway abstracts multiple sources -- Scrapers are isolated per source -- Normalization ensures consistent API output +4. **Database schema** + ```bash + npm run migrate + ``` -## Roadmap -- Add database persistence -- Add more event sources -- Implement rate limiting -- Add caching +5. **Run API** + ```bash + npm start + ``` + Server: http://localhost:3000 + +6. **Optional: background worker** (scheduled / queued scrapes; requires Redis) + ```bash + npm run worker + ``` + +## API + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Service metadata | +| GET | `/api/v1/health` | DB + Redis status | +| GET | `/api/v1/events` | List events (`start_date`, `end_date`, `category`, `source`, `is_free`, `limit`, `offset`) | +| GET | `/api/v1/events/:id` | Single event | +| GET | `/api/v1/events/stats/summary` | Counts / aggregates | +| POST | `/api/v1/events/scrape` | Run scrapers (`X-API-Key` header; optional JSON `{ "source": "eventbrite" \| "meetup" }`) | + +Example: +```bash +curl -s http://localhost:3000/api/v1/events | jq +``` + +Trigger scrape (set `API_KEY` in `.env`): +```bash +curl -s -X POST http://localhost:3000/api/v1/events/scrape \ + -H "Content-Type: application/json" \ + -H "X-API-Key: dev-secret-change-me" \ + -d '{}' +``` -## What I Learned -- Web scraping real-world HTML -- Designing APIs around inconsistent data -- Structuring extensible backend services +## Design notes +- Scrapers are isolated per source; `BaseScraper` normalizes rows and generates stable `external_id` when a source omits ids. +- Without Redis, the API still runs using in-memory rate limits and skips HTTP caching. +- Eventbrite markup changes often; if live selectors find no cards, a single placeholder event is returned so the ingestion path can still be tested end-to-end. +## Roadmap ideas +- Additional sources and richer normalization +- Structured logging / metrics +- Contract tests against recorded HTML fixtures diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 43c0afe..1e88195 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: postgres: image: postgres:15-alpine diff --git a/package.json b/package.json index 75bcc4d..4bc6d6c 100644 --- a/package.json +++ b/package.json @@ -2,15 +2,15 @@ "name": "eventflow-api", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "src/app.js", "scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js", "worker": "node src/jobs/worker.js", "migrate": "node scripts/migrate.js", - "test": "jest || echo 'Tests passed'", - "docker:up": "docker-compose -f docker/docker-compose.yml up -d", - "docker:down": "docker-compose -f docker/docker-compose.yml down", + "test": "node --check src/app.js && node --check src/routes/events.js && node --check src/jobs/scrapeEvents.js", + "docker:up": "docker compose -f docker/docker-compose.yml up -d", + "docker:down": "docker compose -f docker/docker-compose.yml down", "docker:logs": "docker-compose -f docker/docker-compose.yml logs -f" }, "keywords": [], diff --git a/scripts/migrate.js b/scripts/migrate.js index 91df80a..cb607c4 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -3,10 +3,9 @@ const fs = require('fs').promises; const path = require('path'); const { pool} = require('../src/config/database'); +// runs every migrations/*.sql in lexicographic order (prefix filenames with 001_, 002_, …) async function runMigrations() { - console.log('DB_USER @ runtime:',process.env.DB_USER); - try { console.log('Running DB migrations...'); @@ -16,6 +15,14 @@ async function runMigrations() { for(const file of sqlFiles) { + // quick sanity log — helps spot wrong db host when migrations hit the wrong cluster + console.log({ + host: process.env.DB_HOST, + port: process.env.DB_PORT, + user: process.env.DB_USER, + db: process.env.DB_NAME, + }); + console.log(`Running migration: ${file}`); const filePath = path.join(migrationsDir, file); diff --git a/src/app.js b/src/app.js index 81ed19b..6b809ed 100644 --- a/src/app.js +++ b/src/app.js @@ -1,61 +1,88 @@ require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); + +// winston writes under logs/; ensure folder exists on cold start (works on all platforms) +try { + fs.mkdirSync(path.join(process.cwd(), 'logs'), { recursive: true }); +} catch (_) { + /* logs dir exists or cwd not writable */ +} + const express = require('express'); const helmet = require('helmet'); -const app = express(); +const { initializeDatabase } = require('./config/database'); +const { initializeRedis } = require('./config/redis'); +const errorHandler = require('./middleware/errorHandler'); +const createRateLimiter = require('./middleware/rateLimit'); +const eventsRouter = require('./routes/events'); +const healthRouter = require('./routes/health'); + const PORT = process.env.PORT || 3000; -app.use(helmet()); -app.use(express.json()); - -// groot route - shows API info -app.get('/', (req, res) => { - res.json({ - name: 'Event Scraper API', - version: '1.0.0', - status: 'running', - endpoints: { - health: '/api/v1/health', - events: '/api/v1/events' - } - }); -}); - -// health check -app.get('/api/v1/health', (req, res) => { - res.json({ - status: 'ok', - message: 'Server is running!', - timestamp: new Date().toISOString() +// wires postgres + optional redis, then mounts routes (rate limit is applied only under /api/v1/events) +async function createApp() { + await initializeDatabase(); + await initializeRedis(); + + const app = express(); + app.use(helmet()); + app.use(express.json()); + + app.get('/', (req, res) => { + res.json({ + name: 'Eventflow API', + version: '1.0.0', + status: 'running', + endpoints: { + health: '/api/v1/health', + events: '/api/v1/events', + eventById: '/api/v1/events/:id', + eventStats: '/api/v1/events/stats/summary', + scrape: 'POST /api/v1/events/scrape (header: X-API-Key)', + }, + }); }); -}); - -// events endpoint -app.get('/api/v1/events', (req, res) => { - res.json({ - success: true, - data: [], - message: 'Events API endpoint - ready for implementation!' + + app.use('/api/v1/health', healthRouter); + // limiter is created after redis init so it can pick redis vs memory store + app.use('/api/v1/events', createRateLimiter(), eventsRouter); + + app.use((req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Cannot ${req.method} ${req.path}`, + availableEndpoints: [ + 'GET /', + 'GET /api/v1/health', + 'GET /api/v1/events', + 'GET /api/v1/events/:id', + 'GET /api/v1/events/stats/summary', + 'POST /api/v1/events/scrape', + ], + }); }); -}); - -// 404 handler -app.use((req, res) => { - res.status(404).json({ - error: 'Not Found', - message: `Cannot ${req.method} ${req.path}`, - availableEndpoints: [ - 'GET /', - 'GET /api/v1/health', - 'GET /api/v1/events' - ] + + app.use(errorHandler); + return app; +} + +async function main() { + const app = await createApp(); + app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + console.log(`Health: http://localhost:${PORT}/api/v1/health`); + console.log(`Events: http://localhost:${PORT}/api/v1/events`); }); -}); +} -app.listen(PORT, () => { - console.log(`Server running on http://localhost:${PORT}`); - console.log(`Health check: http://localhost:${PORT}/api/v1/health`); - console.log(`Events: http://localhost:${PORT}/api/v1/events`); -}); +// when imported (e.g. tests), only exports createApp — no listen until main runs +if (require.main === module) { + main().catch((err) => { + console.error('Failed to start server', err); + process.exit(1); + }); +} -module.exports = app; +module.exports = { createApp }; diff --git a/src/config/database.js b/src/config/database.js index d901a76..39c0f1f 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -1,29 +1,30 @@ const { Pool } = require('pg'); const logger = require('../utils/logger'); +// defaults match docker/docker-compose.yml so local runs work without a filled .env const pool = new Pool({ - host: process.env.DB_HOST, - port: process.env.DB_PORT, - database: process.env.DB_NAME, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, + host: process.env.DB_HOST || '127.0.0.1', + port: parseInt(process.env.DB_PORT, 10) || 5432, + database: process.env.DB_NAME || 'events_db', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', max: 20, idleTimeoutMillis: 30000, - connectionTimeoutMillis: 2000, + connectionTimeoutMillis: 5000, }); pool.on('error', (err) => { logger.error('Unexpected DB error', { error: err.message }); }); +// verifies connectivity before the api accepts traffic (throws if db is down) async function initializeDatabase() { - try{ - const client = await pool.connect(); + const client = await pool.connect(); + try { + await client.query('SELECT 1'); logger.info('Database connected successfully'); + } finally { client.release(); - }catch (error) { - logger.error('Database connection failed', { error: error.message }); - throw error; } } diff --git a/src/config/queue.js b/src/config/queue.js index 10f4f7d..e0dc6d8 100644 --- a/src/config/queue.js +++ b/src/config/queue.js @@ -1,10 +1,12 @@ const Queue = require('bull'); const logger = require('../utils/logger'); +// worker process uses this queue; bull talks to redis directly (same host/port as app cache) const scrapeQueue = new Queue('event-scraping', { - redis: { - host: process.env.REDIS_HOST, - port: process.env.REDIS_PORT, + redis: { + host: process.env.REDIS_HOST || '127.0.0.1', + port: parseInt(process.env.REDIS_PORT, 10) || 6379, + password: process.env.REDIS_PASSWORD || undefined, }, }); diff --git a/src/config/redis.js b/src/config/redis.js index a1caa2b..a8935c5 100644 --- a/src/config/redis.js +++ b/src/config/redis.js @@ -2,28 +2,69 @@ const redis = require('redis'); const logger = require('../utils/logger'); let client; +let available = false; +// prefer REDIS_URL in production; otherwise host/port with sane defaults +function redisUrl() { + if (process.env.REDIS_URL) { + return process.env.REDIS_URL; + } + const host = process.env.REDIS_HOST || '127.0.0.1'; + const port = process.env.REDIS_PORT || '6379'; + return `redis://${host}:${port}`; +} + +function isRedisAvailable() { + return Boolean(available && client); +} + +// when redis fails or REDIS_ENABLED=false, api still starts (memory rate limits, no http cache) async function initializeRedis() { - client = redis.createClient({ - host: process.env.REDIS_HOST, - port: process.env.REDIS_PORT, - }); - - client.on('error', (err) => { - logger.error('Redis error', { error: err.message }); - }); - - await client.connect(); - logger.info('Redis connected successfully'); - - return client; + if (process.env.REDIS_ENABLED === 'false') { + logger.warn('Redis disabled by REDIS_ENABLED=false'); + return; + } + + try { + client = redis.createClient({ url: redisUrl() }); + client.on('error', (err) => { + logger.error('Redis client error', { error: err.message }); + }); + await client.connect(); + available = true; + logger.info('Redis connected successfully'); + } catch (error) { + client = undefined; + available = false; + logger.warn('Redis unavailable — using in-memory rate limits and no HTTP cache', { + error: error.message, + }); + } } function getRedisClient() { - if(!client){ - throw new Error( 'Redis client not initialized'); + if (!isRedisAvailable()) { + throw new Error('Redis client not initialized'); } return client; } -module.exports = { initializeRedis, getRedisClient }; +// clears list/detail cache keys after inserts so clients never see stale redis payloads +async function invalidateEventsCache() { + if (!isRedisAvailable()) return; + try { + const keys = await getRedisClient().keys('cache:/api/v1/events*'); + if (keys.length > 0) { + await getRedisClient().del(keys); + } + } catch (error) { + logger.warn('Failed to invalidate events cache', { error: error.message }); + } +} + +module.exports = { + initializeRedis, + getRedisClient, + isRedisAvailable, + invalidateEventsCache, +}; diff --git a/src/jobs/scrapeEvents.js b/src/jobs/scrapeEvents.js index 23f69a1..4d0a7b2 100644 --- a/src/jobs/scrapeEvents.js +++ b/src/jobs/scrapeEvents.js @@ -1,62 +1,39 @@ +require('dotenv').config(); const { scrapeQueue } = require('../config/queue'); const scrapingService = require('../services/scrapingService'); const logger = require('../utils/logger'); -// define job processor -scrapeQueue.process(async (job) => { - logger.info('Processing scraping job', { jobId: job.id }); - - const {source} = job.data; - - try { - let results; - - if(source){ - results = await scrapingService.runScraper(source); - } else { - results = await scrapingService.runAllScrapers(); - } - - return results; +// loaded by worker.js; keeps redis + bull running alongside scrape logic +const concurrency = Math.max(1, parseInt(process.env.SCRAPE_JOB_CONCURRENCY, 10) || 1); - } catch (error) { - - logger.error('Job processing failed', { - jobId: job.id, - error: error.message - }); - - throw error; - - } +scrapeQueue.process(concurrency, async (job) => { + logger.info('Processing scrape job', { + jobId: job.id, + name: job.name, + attemptsMade: job.attemptsMade, + }); + return scrapingService.runAllScrapers(); }); -// schedule recurring scraping jobs -async function scheduleScrapingJobs() { - const intervalHours = parseInt(process.env.SCRAPE_INTERVAL_HOURS) || 6; - - // rmove all existing repeat jobs - const repeatableJobs = await scrapeQueue.getRepeatableJobs(); - for (const job of repeatableJobs) { - await scrapeQueue.removeRepeatableByKey(job.key); - } - - // schedule new job - await scrapeQueue.add( - {}, - { - repeat: { - every: intervalHours * 60 * 60 * 1000, // cnvert hours to milliseconds - }, - } - ); - - logger.info(`Scheduled scraping jobs every ${intervalHours} hours`); +// optional fixed-interval job; omit env var to only scrape when jobs are added manually +const intervalMs = parseInt(process.env.SCRAPE_INTERVAL_MS, 10); +if (!Number.isNaN(intervalMs) && intervalMs >= 60_000) { + scrapeQueue + .add( + 'scheduled-scrape', + {}, + { + repeat: { every: intervalMs }, + jobId: 'eventflow-scheduled-scrape', + removeOnComplete: 50, + } + ) + .then(() => { + logger.info('Registered recurring scrape job', { everyMs: intervalMs }); + }) + .catch((err) => { + logger.error('Failed to register recurring scrape job', { error: err.message }); + }); } -// initialize scheduling when module is loaded -scheduleScrapingJobs().catch(err => { - logger.error('Failed to schedule jobs', { error: err.message }); -}); - -module.exports = { scrapeQueue, scheduleScrapingJobs }; \ No newline at end of file +module.exports = { scrapeQueue }; diff --git a/src/middleware/cache.js b/src/middleware/cache.js index d107974..a47d075 100644 --- a/src/middleware/cache.js +++ b/src/middleware/cache.js @@ -1,29 +1,34 @@ -const { getRedisClient } = require('../config/redis'); +const { isRedisAvailable, getRedisClient } = require('../config/redis'); const logger = require('../utils/logger'); +// wraps res.json to store successful json bodies in redis for ttl seconds (query string in key) function cacheMiddleware(duration = 300) { - return async (req, res, next) => { + if (!isRedisAvailable()) { + return next(); + } + const key = `cache:${req.originalUrl}`; - - try{ + + try { const cached = await getRedisClient().get(key); - - if(cached) { + + if (cached) { logger.debug('Cache hit', { key }); return res.json(JSON.parse(cached)); } - - // store original send function + const originalSend = res.json; - - //override send to cache response - res.json = function(data) { - getRedisClient().setEx(key, duration, JSON.stringify(data)); + + // defer setEx so we do not block the response on redis write + res.json = function jsonWithCache(data) { + getRedisClient() + .setEx(key, duration, JSON.stringify(data)) + .catch((err) => logger.error('Cache set failed', { error: err.message })); logger.debug('Cache set', { key, duration }); originalSend.call(this, data); }; - + next(); } catch (error) { logger.error('Cache error', { error: error.message }); @@ -32,4 +37,4 @@ function cacheMiddleware(duration = 300) { }; } -module.exports = cacheMiddleware; \ No newline at end of file +module.exports = cacheMiddleware; diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js index 1709924..10f2f62 100644 --- a/src/middleware/rateLimit.js +++ b/src/middleware/rateLimit.js @@ -1,17 +1,31 @@ const rateLimit = require('express-rate-limit'); -const RedisStore = require('rate-limit-redis'); -const { getRedisClient } = require('../config/redis'); +const { RedisStore } = require('rate-limit-redis'); +const { isRedisAvailable, getRedisClient } = require('../config/redis'); -const limiter = rateLimit({ - store: new RedisStore({ - client: getRedisClient(), - prefix: 'rl:', - }), - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, - max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, - message: 'Too many requests, pls try again later', - standardHeaders: true, - legacyHeaders: false, -}); +// rate-limit-redis v4 expects sendCommand, not a raw client option +function createRateLimiter() { + const windowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000; + const max = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS, 10) || 100; + const base = { + windowMs, + max, + message: 'Too many requests, pls try again later', + standardHeaders: true, + legacyHeaders: false, + }; -module.exports = limiter; \ No newline at end of file + // shared counters across processes when redis exists; otherwise per-process memory store + if (isRedisAvailable()) { + return rateLimit({ + ...base, + store: new RedisStore({ + sendCommand: (...args) => getRedisClient().sendCommand(args), + prefix: 'rl:', + }), + }); + } + + return rateLimit(base); +} + +module.exports = createRateLimiter; diff --git a/src/models/Event.js b/src/models/Event.js index 5219c39..f053ebf 100644 --- a/src/models/Event.js +++ b/src/models/Event.js @@ -1 +1 @@ -// lolz \ No newline at end of file +module.exports = {}; diff --git a/src/routes/events.js b/src/routes/events.js index 70b1d7b..394c89f 100644 --- a/src/routes/events.js +++ b/src/routes/events.js @@ -3,14 +3,11 @@ const router = express.Router(); const eventService = require('../services/eventService'); const scrapingService = require('../services/scrapingService'); const authenticate = require('../middleware/auth'); -const rateLimiter = require('../middleware/rateLimit'); const cacheMiddleware = require('../middleware/cache'); const logger = require('../utils/logger'); -// apply rate limiting to all routes -router.use(rateLimiter); - -// GET /api/v1/events - list all events +// rate limit applied in app.js; cache ttl 5m for list responses +// get /api/v1/events — list all events router.get('/', cacheMiddleware(300), async (req, res, next) => { try{ @@ -40,7 +37,7 @@ router.get('/', cacheMiddleware(300), async (req, res, next) => { } }); -// GET /api/v1/events/:id - get single event +// get /api/v1/events/:id — single event router.get('/:id', cacheMiddleware(300), async (req, res, next) => { try{ @@ -63,7 +60,8 @@ router.get('/:id', cacheMiddleware(300), async (req, res, next) => { }); -// POST /api/v1/events/scrape - Trigger scraping manually +// requires header x-api-key matching process.env.API_KEY (postman: use headers tab, not query params) +// post /api/v1/events/scrape — trigger scraping manually router.post('/scrape', authenticate, async (req, res, next) => { try{ @@ -89,7 +87,7 @@ router.post('/scrape', authenticate, async (req, res, next) => { } }); -// GET /api/v1/events/stats - get statistics +// get /api/v1/events/stats/summary — aggregates router.get('/stats/summary', cacheMiddleware(600), async (req, res, next) => { try{ const stats = await eventService.getEventStats(); diff --git a/src/routes/health.js b/src/routes/health.js index 599062f..8f3e3ad 100644 --- a/src/routes/health.js +++ b/src/routes/health.js @@ -1,8 +1,9 @@ const express = require('express'); const router = express.Router(); const { pool } = require('../config/database'); -const { getRedisClient } = require('../config/redis'); +const { isRedisAvailable, getRedisClient } = require('../config/redis'); +// returns 200 only when db + optional redis are usable; skipped redis is not_configured, not unhealthy router.get('/', async (req, res) => { const health = { status: 'ok', @@ -14,7 +15,6 @@ router.get('/', async (req, res) => { }; try { - // check db await pool.query('SELECT 1'); health.services.database = 'healthy'; } catch (error) { @@ -22,17 +22,20 @@ router.get('/', async (req, res) => { health.status = 'degraded'; } - try { - // check Redis - await getRedisClient().ping(); - health.services.redis = 'healthy'; - } catch (error) { - health.services.redis = 'unhealthy'; - health.status = 'degraded'; + if (!isRedisAvailable()) { + health.services.redis = 'not_configured'; + } else { + try { + await getRedisClient().ping(); + health.services.redis = 'healthy'; + } catch (error) { + health.services.redis = 'unhealthy'; + health.status = 'degraded'; + } } - const statusCode = health.status === 'ok' ? 200 : 503; + const statusCode = health.status === 'ok' ? 200 : 503; res.status(statusCode).json(health); }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/scrapers/Base/BaseScraper.js b/src/scrapers/Base/BaseScraper.js index 9348287..09fa5a0 100644 --- a/src/scrapers/Base/BaseScraper.js +++ b/src/scrapers/Base/BaseScraper.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const logger = require('../../utils/logger'); class BaseScraper { @@ -6,17 +7,47 @@ class BaseScraper { } async scrape() { - throw new Error('scrape() must be implemented by subclass'); + throw new Error('scrape() must be implemented by subclass'); } + // postgres upserts on external_id — derive one when upstream omits stable ids + stableExternalId(rawEvent) { + if (rawEvent.id) { + return String(rawEvent.id); + } + const basis = `${this.name}|${rawEvent.title || ''}|${rawEvent.startDate || ''}|${rawEvent.url || ''}`; + const hash = crypto.createHash('sha256').update(basis).digest('hex').slice(0, 32); + return `${this.name}-${hash}`; + } + + // maps messy scraper output to eventService.createEvent shape; returns null to drop bad rows normalizeEvent(rawEvent) { + const start = new Date(rawEvent.startDate); + if (Number.isNaN(start.getTime())) { + logger.warn('Skipping event with invalid start date', { + source: this.name, + title: rawEvent.title, + }); + return null; + } + + const end = rawEvent.endDate ? new Date(rawEvent.endDate) : null; + const endDate = end && !Number.isNaN(end.getTime()) ? end : null; + + let price = rawEvent.price; + // strip currency symbols before inserting into numeric column + if (typeof price === 'string' && price.trim() !== '') { + const n = parseFloat(price.replace(/[^0-9.]/g, '')); + price = Number.isNaN(n) ? null : n; + } + return { - external_id: rawEvent.id, + external_id: this.stableExternalId(rawEvent), source: this.name, - title: rawEvent.title, - description: rawEvent.description, - start_date: new Date(rawEvent.startDate), - end_date: rawEvent.endDate ? new Date(rawEvent.endDate) : null, + title: rawEvent.title || 'Untitled event', + description: rawEvent.description || null, + start_date: start, + end_date: endDate, location: { name: rawEvent.location?.name, address: rawEvent.location?.address, @@ -24,12 +55,12 @@ class BaseScraper { lat: rawEvent.location?.lat, lng: rawEvent.location?.lng, }, - category: rawEvent.category, - url: rawEvent.url, - image_url: rawEvent.imageUrl, + category: rawEvent.category || null, + url: rawEvent.url || null, + image_url: rawEvent.imageUrl || null, is_free: rawEvent.isFree || false, - price: rawEvent.price || null, - organizer: rawEvent.organizer, + price: price ?? null, + organizer: rawEvent.organizer || null, raw_data: rawEvent, }; } @@ -38,8 +69,8 @@ class BaseScraper { try { logger.info(`Starting scraper: ${this.name}`); const events = await this.scrape(); - logger.info(`Scraper ${this.name} found ${events.length} events`); - return events.map(e => this.normalizeEvent(e)); + logger.info(`Scraper ${this.name} parsed ${events.length} raw rows`); + return events.map((e) => this.normalizeEvent(e)).filter(Boolean); } catch (error) { logger.error(`Scraper ${this.name} failed`, { error: error.message }); throw error; @@ -47,4 +78,4 @@ class BaseScraper { } } -module.exports = BaseScraper; \ No newline at end of file +module.exports = BaseScraper; diff --git a/src/scrapers/eventbrite.js b/src/scrapers/eventbrite.js index 01fdd20..0dd072b 100644 --- a/src/scrapers/eventbrite.js +++ b/src/scrapers/eventbrite.js @@ -1,6 +1,7 @@ const BaseScraper = require('./Base/BaseScraper'); const cheerio = require('cheerio'); const axios = require('axios'); +const logger = require('../utils/logger'); class EventbriteScraper extends BaseScraper { constructor() { @@ -10,23 +11,25 @@ class EventbriteScraper extends BaseScraper { async scrape() { const events = []; - + try { - // ex: sraping eventbrite search page for tech events + // listing html changes often — selectors below may return zero rows (see fallback below) const url = `${this.baseUrl}/d/online/tech--events/`; const response = await axios.get(url, { headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'text/html,application/xhtml+xml', }, + timeout: 25_000, }); const $ = cheerio.load(response.data); - - // actual selectors will vary - // TODO: update selectors based on eventbrites current HTML structure + + // card class names are brittle; update when eventbrite redesigns search results $('.search-event-card').each((i, element) => { const $el = $(element); - + events.push({ id: $el.attr('data-event-id'), title: $el.find('.event-card__title').text().trim(), @@ -35,12 +38,33 @@ class EventbriteScraper extends BaseScraper { location: { name: $el.find('.event-card__location').text().trim(), }, - url: $el.find('a').attr('href'), - imageUrl: $el.find('img').attr('src'), - isFree: $el.find('.event-card__price').text().includes('Free'), + url: $el.find('a').first().attr('href'), + imageUrl: $el.find('img').first().attr('src'), + isFree: $el.find('.event-card__price').text().toLowerCase().includes('free'), organizer: $el.find('.event-card__organizer').text().trim(), }); }); + + // keeps local/demo pipelines green when markup no longer matches + if (events.length === 0) { + logger.warn( + 'Eventbrite: no cards matched current selectors; returning a static placeholder row so the pipeline stays testable' + ); + const soon = new Date(); + soon.setDate(soon.getDate() + 7); + events.push({ + id: 'eventbrite-placeholder', + title: 'Discover tech events on Eventbrite', + description: 'Update selectors in scrapers/eventbrite.js when Eventbrite HTML changes.', + startDate: soon.toISOString(), + endDate: null, + location: { name: 'Online', city: 'Various' }, + url: url, + category: 'Technology', + isFree: true, + organizer: 'Eventbrite', + }); + } } catch (error) { throw new Error(`Eventbrite scraping failed: ${error.message}`); } diff --git a/src/scrapers/eventbriteScraper.js b/src/scrapers/eventbriteScraper.js deleted file mode 100644 index bb209ba..0000000 --- a/src/scrapers/eventbriteScraper.js +++ /dev/null @@ -1,38 +0,0 @@ -const axios = require('axios'); -const cheerio = require('cheerio'); - -class EventbriteScraper { - constructor() { - this.name = 'eventbrite'; - } - - async scrape() { - console.log(`Starting ${this.name} scraper...`); - const events = []; - - try{ - // mock event for demo - events.push({ - external_id: 'demo-evt-1', - source: this.name, - title: 'Tech Meetup 2024', - description: 'A gathering of tech enthusiasts', - start_date: new Date('2024-03-15T18:00:00Z'), - end_date: new Date('2024-03-15T20:00:00Z'), - location: { name: 'Online', city: 'Virtual' }, - category: 'Technology', - url: 'https://example.com/event1', - is_free: true, - organizer: 'Tech Community', - }); - - console.log(`${this.name} found ${events.length} events`); - } catch (error) { - console.error(`${this.name} scraping failed:`, error.message); - } - - return events; - } -} - -module.exports = new EventbriteScraper(); diff --git a/src/services/scrapingService.js b/src/services/scrapingService.js index 81b94ba..ccf53b2 100644 --- a/src/services/scrapingService.js +++ b/src/services/scrapingService.js @@ -1,7 +1,9 @@ const scrapers = require('../scrapers'); const eventService = require('./eventService'); const logger = require('../utils/logger'); +const { invalidateEventsCache } = require('../config/redis'); +// orchestrates scrapers and persists rows; invalidates redis http cache after writes class ScrapingService { async runAllScrapers() { @@ -28,6 +30,8 @@ class ScrapingService { } } + // so GET /events does not keep serving empty cached bodies after a successful scrape + await invalidateEventsCache(); return results; } @@ -44,6 +48,8 @@ class ScrapingService { await eventService.createEvent(event); } + // same cache bust as runAllScrapers so single-source runs refresh cached lists + await invalidateEventsCache(); return {source: scraperName, count: events.length}; } } From 6c5b920ff4a8bc92bdf8d9ee81aa8c9b409a7983 Mon Sep 17 00:00:00 2001 From: Anjali <101072121+PatidarAnjali@users.noreply.github.com> Date: Sat, 2 May 2026 00:11:42 -0400 Subject: [PATCH 2/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/config/redis.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/config/redis.js b/src/config/redis.js index a8935c5..ae8ab53 100644 --- a/src/config/redis.js +++ b/src/config/redis.js @@ -53,9 +53,22 @@ function getRedisClient() { async function invalidateEventsCache() { if (!isRedisAvailable()) return; try { - const keys = await getRedisClient().keys('cache:/api/v1/events*'); - if (keys.length > 0) { - await getRedisClient().del(keys); + const redisClient = getRedisClient(); + const batch = []; + + for await (const key of redisClient.scanIterator({ + MATCH: 'cache:/api/v1/events*', + COUNT: 100, + })) { + batch.push(key); + if (batch.length >= 100) { + await redisClient.del(batch); + batch.length = 0; + } + } + + if (batch.length > 0) { + await redisClient.del(batch); } } catch (error) { logger.warn('Failed to invalidate events cache', { error: error.message }); From 31923ca2e99fe6db03161379381156a8e3f0869e Mon Sep 17 00:00:00 2001 From: Anjali <101072121+PatidarAnjali@users.noreply.github.com> Date: Sat, 2 May 2026 00:11:58 -0400 Subject: [PATCH 3/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/middleware/cache.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/middleware/cache.js b/src/middleware/cache.js index a47d075..de190ea 100644 --- a/src/middleware/cache.js +++ b/src/middleware/cache.js @@ -22,11 +22,16 @@ function cacheMiddleware(duration = 300) { // defer setEx so we do not block the response on redis write res.json = function jsonWithCache(data) { - getRedisClient() - .setEx(key, duration, JSON.stringify(data)) - .catch((err) => logger.error('Cache set failed', { error: err.message })); - logger.debug('Cache set', { key, duration }); - originalSend.call(this, data); + if (this.statusCode >= 200 && this.statusCode < 300) { + getRedisClient() + .setEx(key, duration, JSON.stringify(data)) + .catch((err) => logger.error('Cache set failed', { error: err.message })); + logger.debug('Cache set', { key, duration }); + } else { + logger.debug('Skipping cache for non-success response', { key, statusCode: this.statusCode }); + } + + return originalSend.call(this, data); }; next(); From 2503d2ebf25391f1aa17614e044a0574d84bb0dd Mon Sep 17 00:00:00 2001 From: Anjali <101072121+PatidarAnjali@users.noreply.github.com> Date: Sat, 2 May 2026 00:12:07 -0400 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/scrapers/eventbrite.js | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/scrapers/eventbrite.js b/src/scrapers/eventbrite.js index 0dd072b..e2aaba6 100644 --- a/src/scrapers/eventbrite.js +++ b/src/scrapers/eventbrite.js @@ -45,25 +45,11 @@ class EventbriteScraper extends BaseScraper { }); }); - // keeps local/demo pipelines green when markup no longer matches + // when selectors no longer match, do not fabricate data for downstream consumers if (events.length === 0) { logger.warn( - 'Eventbrite: no cards matched current selectors; returning a static placeholder row so the pipeline stays testable' + 'Eventbrite: no cards matched current selectors; returning an empty result set' ); - const soon = new Date(); - soon.setDate(soon.getDate() + 7); - events.push({ - id: 'eventbrite-placeholder', - title: 'Discover tech events on Eventbrite', - description: 'Update selectors in scrapers/eventbrite.js when Eventbrite HTML changes.', - startDate: soon.toISOString(), - endDate: null, - location: { name: 'Online', city: 'Various' }, - url: url, - category: 'Technology', - isFree: true, - organizer: 'Eventbrite', - }); } } catch (error) { throw new Error(`Eventbrite scraping failed: ${error.message}`); From 09d1ec15fd1f65011954cf9e994dbe4e1ad9d7a0 Mon Sep 17 00:00:00 2001 From: Anjali <101072121+PatidarAnjali@users.noreply.github.com> Date: Sat, 2 May 2026 00:12:28 -0400 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/config/redis.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config/redis.js b/src/config/redis.js index ae8ab53..dbd59d3 100644 --- a/src/config/redis.js +++ b/src/config/redis.js @@ -11,7 +11,9 @@ function redisUrl() { } const host = process.env.REDIS_HOST || '127.0.0.1'; const port = process.env.REDIS_PORT || '6379'; - return `redis://${host}:${port}`; + const password = process.env.REDIS_PASSWORD; + const auth = password ? `:${encodeURIComponent(password)}@` : ''; + return `redis://${auth}${host}:${port}`; } function isRedisAvailable() { From e0345bf0d3b5926398c9e7d8042e105b1174a62d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:13:11 +0000 Subject: [PATCH 6/7] fix: move /stats/summary and /scrape routes before /:id to prevent param capture Agent-Logs-Url: https://github.com/PatidarAnjali/Eventflow-API/sessions/ac87f394-a662-46d1-953c-12250e9ad1d1 Co-authored-by: PatidarAnjali <101072121+PatidarAnjali@users.noreply.github.com> --- src/routes/events.js | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/routes/events.js b/src/routes/events.js index 394c89f..068d759 100644 --- a/src/routes/events.js +++ b/src/routes/events.js @@ -37,27 +37,18 @@ router.get('/', cacheMiddleware(300), async (req, res, next) => { } }); -// get /api/v1/events/:id — single event -router.get('/:id', cacheMiddleware(300), async (req, res, next) => { - +// get /api/v1/events/stats/summary — aggregates (must be before /:id to avoid param capture) +router.get('/stats/summary', cacheMiddleware(600), async (req, res, next) => { try{ - const event = await eventService.getEventById(req.params.id); + const stats = await eventService.getEventStats(); - if (!event) { - return res.status(404).json({ - success: false, - error: 'Event not found', - }); - } - res.json({ success: true, - data: event, + data: stats, }); - } catch(error) { + } catch (error) { next(error); } - }); // requires header x-api-key matching process.env.API_KEY (postman: use headers tab, not query params) @@ -87,18 +78,27 @@ router.post('/scrape', authenticate, async (req, res, next) => { } }); -// get /api/v1/events/stats/summary — aggregates -router.get('/stats/summary', cacheMiddleware(600), async (req, res, next) => { +// get /api/v1/events/:id — single event +router.get('/:id', cacheMiddleware(300), async (req, res, next) => { + try{ - const stats = await eventService.getEventStats(); + const event = await eventService.getEventById(req.params.id); + if (!event) { + return res.status(404).json({ + success: false, + error: 'Event not found', + }); + } + res.json({ success: true, - data: stats, + data: event, }); - } catch (error) { + } catch(error) { next(error); } + }); module.exports = router; \ No newline at end of file From 0552404cd12e0d0a5b55627488cd96e9c05180ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 04:17:26 +0000 Subject: [PATCH 7/7] fix: prefix external_id with source, distinguish redis disabled vs failed, support REDIS_URL in queue Agent-Logs-Url: https://github.com/PatidarAnjali/Eventflow-API/sessions/90d0f314-5e13-438b-b51b-4bdb1cdbd3d8 Co-authored-by: PatidarAnjali <101072121+PatidarAnjali@users.noreply.github.com> --- src/config/queue.js | 22 ++++++++++++++-------- src/config/redis.js | 8 ++++++++ src/routes/health.js | 9 +++++++-- src/scrapers/Base/BaseScraper.js | 5 +++-- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/config/queue.js b/src/config/queue.js index e0dc6d8..e45a88c 100644 --- a/src/config/queue.js +++ b/src/config/queue.js @@ -1,14 +1,20 @@ const Queue = require('bull'); const logger = require('../utils/logger'); -// worker process uses this queue; bull talks to redis directly (same host/port as app cache) -const scrapeQueue = new Queue('event-scraping', { - redis: { - host: process.env.REDIS_HOST || '127.0.0.1', - port: parseInt(process.env.REDIS_PORT, 10) || 6379, - password: process.env.REDIS_PASSWORD || undefined, - }, -}); +// prefer REDIS_URL (production / TLS setups); fall back to building a URL from host/port/password +function getRedisUrl() { + if (process.env.REDIS_URL) { + return process.env.REDIS_URL; + } + const host = process.env.REDIS_HOST || '127.0.0.1'; + const port = process.env.REDIS_PORT || '6379'; + const password = process.env.REDIS_PASSWORD; + const auth = password ? `:${encodeURIComponent(password)}@` : ''; + return `redis://${auth}${host}:${port}`; +} + +// worker process uses this queue; bull talks to redis directly (same connection as app cache) +const scrapeQueue = new Queue('event-scraping', getRedisUrl()); scrapeQueue.on('error', (error) => { logger.error('Queue error', { error: error.message }); diff --git a/src/config/redis.js b/src/config/redis.js index dbd59d3..c8022c7 100644 --- a/src/config/redis.js +++ b/src/config/redis.js @@ -3,6 +3,7 @@ const logger = require('../utils/logger'); let client; let available = false; +let configured = false; // true when redis init was attempted (not explicitly disabled) // prefer REDIS_URL in production; otherwise host/port with sane defaults function redisUrl() { @@ -20,6 +21,11 @@ function isRedisAvailable() { return Boolean(available && client); } +// true when redis was intended to be used (REDIS_ENABLED != 'false'); false only when explicitly disabled +function isRedisConfigured() { + return configured; +} + // when redis fails or REDIS_ENABLED=false, api still starts (memory rate limits, no http cache) async function initializeRedis() { if (process.env.REDIS_ENABLED === 'false') { @@ -27,6 +33,7 @@ async function initializeRedis() { return; } + configured = true; // redis was intended — connection failures are real errors try { client = redis.createClient({ url: redisUrl() }); client.on('error', (err) => { @@ -81,5 +88,6 @@ module.exports = { initializeRedis, getRedisClient, isRedisAvailable, + isRedisConfigured, invalidateEventsCache, }; diff --git a/src/routes/health.js b/src/routes/health.js index 8f3e3ad..2091ff5 100644 --- a/src/routes/health.js +++ b/src/routes/health.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const { pool } = require('../config/database'); -const { isRedisAvailable, getRedisClient } = require('../config/redis'); +const { isRedisAvailable, isRedisConfigured, getRedisClient } = require('../config/redis'); // returns 200 only when db + optional redis are usable; skipped redis is not_configured, not unhealthy router.get('/', async (req, res) => { @@ -22,8 +22,13 @@ router.get('/', async (req, res) => { health.status = 'degraded'; } - if (!isRedisAvailable()) { + if (!isRedisConfigured()) { + // intentionally disabled via REDIS_ENABLED=false — not a health concern health.services.redis = 'not_configured'; + } else if (!isRedisAvailable()) { + // was intended but failed to connect at startup — treat as unhealthy + health.services.redis = 'unhealthy'; + health.status = 'degraded'; } else { try { await getRedisClient().ping(); diff --git a/src/scrapers/Base/BaseScraper.js b/src/scrapers/Base/BaseScraper.js index 09fa5a0..61ad4ce 100644 --- a/src/scrapers/Base/BaseScraper.js +++ b/src/scrapers/Base/BaseScraper.js @@ -10,10 +10,11 @@ class BaseScraper { throw new Error('scrape() must be implemented by subclass'); } - // postgres upserts on external_id — derive one when upstream omits stable ids + // postgres upserts on external_id — always prefix with source so ids from different + // sources never collide on the global unique constraint stableExternalId(rawEvent) { if (rawEvent.id) { - return String(rawEvent.id); + return `${this.name}-${rawEvent.id}`; } const basis = `${this.name}|${rawEvent.title || ''}|${rawEvent.startDate || ''}|${rawEvent.url || ''}`; const hash = crypto.createHash('sha256').update(basis).digest('hex').slice(0, 32);