# HotNow Savannah -- Technical Features, Enhancements & Implementation Guide **Date:** August 11, 2026 **Product:** HotNow (hotnow.io) -- Savannah Metro Launch **Classification:** IT Pro Partner -- Internal Technical Documentation **Status:** Draft for Germaine Review --- ## Table of Contents 1. [Architecture Overview](#1-architecture-overview) 2. [New Technical Features](#2-new-technical-features) 3. [Data Model](#3-data-model) 4. [API Surface](#4-api-surface) 5. [Ranking Algorithm](#5-ranking-algorithm) 6. [Implementation Roadmap](#6-implementation-roadmap) 7. [Infrastructure & Deployment](#7-infrastructure--deployment) 8. [Integration Points](#8-integration-points) 9. [Testing & QA Strategy](#9-testing--qa-strategy) 10. [Operational Runbook](#10-operational-runbook) --- ## 1. Architecture Overview ### 1.1 System Diagram (Savannah-Focused) ``` +-------------------------------------------------------------------+ | USERS (PWA + Web) | | app.hotnow.io/savannah -- Map View -- Discovery Feed -- | | Neighborhood Browser -- Ambassador Portal -- Auth | +-------------------------------------------------------------------+ | v +-------------------------------------------------------------------+ | CLOUDFLARE CDN / DNS | | Static assets (JS/CSS/icons) | DDoS protection | DNS routing | | hotnow.io A -> 152.53.192.33 | app/api/admin subdomains | +-------------------------------------------------------------------+ | v +-------------------------------------------------------------------+ | CADDY REVERSE PROXY (Core: 152.53.192.33) | | SSL termination (LetsEncrypt) | Rate limiting | Route by | | subdomain | hotnow.io -> /var/www/hotnow/ | api -> :8001 | +-------------------------------------------------------------------+ | +-------------------+-------------------+ v v v +------------------+ +------------------+ +------------------+ | hotnow.io | | api.hotnow.io | | admin.hotnow.io | | (Marketing Pg) | | (FastAPI :8001) | | (Static Admin) | | /var/www/hotnow | | /root/hotnow-api| | /var/www/hotnow- | | | | | | admin/ | +------------------+ +------------------+ +------------------+ | +-------------------+-------------------+-------------------+ v v v v +------------------+ +------------------+ +------------------+ +------------------+ | PostgreSQL 16 | | Redis 7 | | Super Search v2 | | HotNow MCP | | + PostGIS 3 | | Cache + Pub/Sub | | (FastMCP :8899) | | Server | | hotnow DB | | trending:* keys | | 7 providers | | (FastMCP :8901) | | Core :5432 | | Core :6379 | | /root/docker/ | | /opt/hotnow-mcp/ | | | | | | super-search/ | | | +------------------+ +------------------+ +------------------+ +------------------+ | | | | v v v v +-------------------------------------------------------------------+ | EXTERNAL SERVICES | | Stripe | Mapbox GL | Eventbrite API | Ticketmaster API | | Meetup | PRAW (Reddit) | admin-ai (LiteLLM / deepseek-v4-pro) | | Yelp Fusion | Google Places | Instagram Graph API | +-------------------------------------------------------------------+ ``` ### 1.2 Component Breakdown | Component | Location | Technology | Status | |-----------|----------|------------|--------| | **PWA Frontend** | `/var/www/hotnow-app/` | SvelteKit (static export) + Leaflet.js | Placeholder exists | | **Marketing Landing** | `/var/www/hotnow/` | Static HTML/CSS | Has index.html (1342 lines) | | **REST API** | `/root/hotnow-api/` | FastAPI 0.115+ (Python 3.13) | Service running (hotnow-api.service), partial impl | | **PostgreSQL + PostGIS** | Core:5432 | PostgreSQL 16 + PostGIS 3 | Running; hotnow DB needs schema | | **Redis** | Core:6379 | Redis 7 (allkeys-lru) | Running; key namespace needed | | **Super Search v2 MCP** | `/root/docker/super-search/` | FastMCP 4.x, Streamable HTTP | Running (:8899), 17 tools, 7 providers | | **HotNow MCP Server** | `/opt/hotnow-mcp/` (to create) | FastMCP 4.x | Not yet built | | **Caddy Reverse Proxy** | `/etc/caddy/Caddyfile` | Caddy 2.x | hotnow routes already configured | | **Systemd Services** | `/etc/systemd/system/` | systemd | hotnow-api.service, super-search.service | | **ARQ Task Queue** | Core (to deploy) | ARQ + Redis | Not yet deployed | | **Reddit Automation** | Core (cron) | PRAW + cron | Not yet built | | **AI Model** | admin-ai (LiteLLM) | deepseek-v4-pro | Operational | | **Stripe** | API integration | Subscriptions + Webhooks | Auth key reserved, not wired | | **Mapbox** | PWA (Leaflet.js) | OpenStreetMap tiles (free tier) | Mapbox optional for satellite/3D | ### 1.3 Savannah Metro Scope Savannah metro area boundaries for initial launch: | Zone | Neighborhoods | Est. Venues | |------|--------------|-------------| | **Historic District** | Downtown, River Street, City Market, Broughton St | 80-120 | | **Starland / Thomas Square** | Starland District, Bull Street, Forsyth Park area | 40-60 | | **Midtown / Southside** | Abercorn corridor, Oglethorpe Mall area | 30-50 | | **Tybee Island** | Beachfront bars, pier, restaurants | 20-30 | | **Pooler** | Airport area, outlet malls, chain restaurants | 25-40 | | **SCAD Campus Area** | Buildings, galleries, student venues | 15-25 | | **Total Seed Target** | | **200-300 venues** | GeoJSON boundaries stored in `neighborhoods` table (PostGIS polygons). Corridor browsing via spatial queries against these boundaries plus linear buffers along named streets. --- ## 2. New Technical Features ### 2.1 Real-Time Ranking Algorithm **Problem:** Existing platforms rank by accumulated reviews (Yelp), date (Eventbrite), or manual curation (Thrillist). None answer "what's hot right now." **Solution:** Multi-signal scoring engine that recalculates every 5 minutes via ARQ cron job. #### Signal Categories | Signal | Weight | Source | Decay Model | Refresh | Implementation | |--------|--------|--------|-------------|---------|---------------| | **Freshness** | 30% | How recently posted/updated/checked-into | Exponential half-life: 6h | Every 5 min | PostgreSQL `updated_at` + `created_at` columns | | **Velocity** | 25% | Social mention acceleration, check-in velocity, view/clicks/min | Exponential half-life: 2h | Every 5 min | Redis pulse counters + rolling window | | **Social Proof** | 20% | Instagram/TikTok mention counts, Reddit mentions | Exponential half-life: 4h | Every 15 min | Super Search v2 social crawling | | **Contextual** | 15% | Weather, time of day, day of week, proximity | Contextual (no decay) | Every 30 min | OpenWeatherMap API + PostgreSQL time functions | | **Manual Boost** | 10% | Featured Placement ($97/mo), Event Boost ($47) | Fixed duration | On purchase | Business feature table | #### Core Formula ``` HOT_SCORE = ( FRESHNESS(x) * W_f + VELOCITY(x) * W_v + SOCIAL_PROOF(x) * W_s + CONTEXTUAL(x) * W_c + MANUAL_BOOST(x) * W_m ) * CITY_NORMALIZATION * TIME_BOOST ``` Where: - `W_f=0.30, W_v=0.25, W_s=0.20, W_c=0.15, W_m=0.10` - `CITY_NORMALIZATION = log10(active_users + 1) / log10(total_events + 1)` -- prevents large cities from dominating; Savannah benefits from small denominator - `TIME_BOOST = ramp_up(t)`: linearly increases from 24h before event start, peaks at 2h before, decays after event starts #### Velocity Calculation ```python def velocity_score(event_id: str) -> float: """Rolling 2-hour window of engagement metrics.""" now = datetime.utcnow() window_start = now - timedelta(hours=2) # Redis sorted set: pulse:{event_id} -> [{timestamp, metric_type}] views = redis.zcount(f"pulse:{event_id}:views", window_start.timestamp(), now.timestamp()) clicks = redis.zcount(f"pulse:{event_id}:clicks", window_start.timestamp(), now.timestamp()) saves = redis.zcount(f"pulse:{event_id}:saves", window_start.timestamp(), now.timestamp()) # Weighted and normalized raw_velocity = (views * 0.3 + clicks * 0.4 + saves * 0.5) return math.log1p(raw_velocity) / 10.0 # Cap at ~1.0 ``` #### Anti-Gaming Measures - Velocity cap: engagement increase >300% in 15 min -> throttled to previous 15-min rate - Bot detection: same IP rapid-fire views -> excluded from pulse counters - User-submitted events: require admin approval before entering trending - Manual boost transparency: labeled "Promoted" in UI, tracked separately in analytics - Minimum signal threshold: events with <5 total signals get `trending_score = 0.0` #### Fallback Strategy When social signals are sparse (common in Savannah at launch): 1. **Freshness dominates** -- recently added/updated content naturally ranks 2. **Manual curation boost** -- admin-curated events get weight multiplier (1.5x) 3. **Randomized tiebreaks** -- prevents stale top-of-list from accumulating 4. **Proximity boost** -- events closest to user's current location get +0.05 score #### Implementation Location ``` /root/hotnow-api/ranking.py # Score calculator /root/hotnow-api/trend_worker.py # ARQ cron job (every 5 min) /root/hotnow-api/signals/ # Per-signal modules freshness.py # Time-decay calculations velocity.py # Redis pulse aggregation social.py # Social mention counting contextual.py # Weather/time/proximity boost.py # Manual boost management ``` #### Redis Key Schema for Ranking ``` trending:savannah:all Sorted Set {event_id: score} trending:savannah:music Sorted Set {event_id: score} trending:savannah:food_drink Sorted Set {event_id: score} trending:savannah:arts_culture Sorted Set {event_id: score} trending:savannah:nightlife Sorted Set {event_id: score} trending:savannah:pop_up Sorted Set {event_id: score} pulse:{event_id}:views Sorted Set {timestamp: count} pulse:{event_id}:clicks Sorted Set {timestamp: count} pulse:{event_id}:saves Sorted Set {timestamp: count} ``` --- ### 2.2 Structured AI-Readable Data Layer (PeerPush Pattern) **Problem:** AI assistants (ChatGPT, Claude, Perplexity) cannot query HotNow data. Users asking "what's hot in Savannah tonight?" to AI get no answer. This is the next SEO frontier. **Solution:** Every listing and page emits structured data in standardized formats consumable by AI crawlers and LLMs. #### Implementation Layers **Layer 1: Schema.org Markup (HTML pages)** Every venue and event detail page includes JSON-LD: ```html ``` **Layer 2: Open Graph and Twitter Cards** ```html ``` **Layer 3: Sitemap with Structured Data** `https://hotnow.io/sitemap-savannah.xml`: ```xml https://hotnow.io/savannah/event/550e8400-e29b 2026-08-11 hourly 0.9 ``` **Layer 4: AI-Readable API Endpoints** ``` GET /api/v1/ai/events?city=savannah&date=today -> Returns JSON optimized for LLM context windows -> Includes venue info, prices, times, descriptions -> Structured for function-calling consumption GET /api/v1/ai/trending?city=savannah -> Compact JSON with rank, name, category, score -> Designed for <500 token response ``` #### Implementation Plan | Step | File | Effort | |------|------|--------| | SvelteKit JSON-LD component | `src/lib/components/StructuredData.svelte` | 3h | | OG + Twitter meta tags | `src/routes/event/[id]/+page.svelte` | 2h | | Sitemap generator (cron) | `sitemap_generator.py` + `crontab` | 4h | | AI-readable API endpoints | `/root/hotnow-api/routers/ai_readable.py` | 5h | | **Total** | | **14 hours** | --- ### 2.3 MCP Server Integration **Problem:** Developers and AI agents need programmatic access to HotNow data in the same way they query Super Search v2. The MCP protocol is becoming the standard for AI-tool integration. **Solution:** New FastMCP 4.x server exposing HotNow as MCP tools -- same pattern as Super Search v2 (already proven on Core). #### Architecture ``` +-------------------+ +--------------------+ +------------------+ | AI Client / Agent | <---> | HotNow MCP Server | <---> | PostgreSQL | | (e.g., Hermes) | MCP | (FastMCP :8901) | SQL | + Redis | +-------------------+ +--------------------+ +------------------+ | v +------------------+ | Super Search v2 | | (MCP :8899) | +------------------+ ``` #### MCP Tools | Tool | Description | Parameters | |------|-------------|------------| | `hotnow_search_events` | Search events by query, category, date range, location | `query` (str), `category` (optional), `city` (str), `date_from` (optional), `date_to` (optional), `limit` (int=10) | | `hotnow_get_venue` | Get full venue details with upcoming events | `venue_id` (UUID) or `venue_name` (str) | | `hotnow_trending` | Get trending events in a city/neighborhood | `city` (str), `neighborhood` (optional), `category` (optional), `limit` (int=10) | | `hotnow_neighborhood` | List neighborhoods for a city with venue counts | `city` (str) | | `hotnow_recommendations` | AI-powered personalized recommendations | `city` (str), `preferences` (list[str]), `lat` (float), `lng` (float) | | `hotnow_venue_search` | Search venues by name, type, or neighborhood | `query` (str), `city` (str), `venue_type` (optional), `limit` (int=10) | | `hotnow_health` | Health check -- returns status of DB, Redis, Super Search | (none) | #### Implementation Pattern (matching Super Search v2) ```python #!/usr/bin/env python3 """HotNow MCP Server v1.0.0 -- FastMCP 4.x, Streamable HTTP.""" from __future__ import annotations from fastmcp import FastMCP import asyncpg mcp = FastMCP("HotNow Savannah MCP") @mcp.tool(description="Search events in Savannah metro area. Returns events matching query with venue info, times, and trending scores.") async def hotnow_search_events( query: str, category: str | None = None, city: str = "Savannah", date_from: str | None = None, date_to: str | None = None, limit: int = 10, ) -> str: """Search events with full-text search + category + date filters.""" conn = await _get_db() # ... SQL query with tsvector, PostGIS proximity ... return json.dumps(results, indent=2, ensure_ascii=False) @mcp.tool(description="Get trending events in Savannah. Returns real-time ranked events with scores, venue info, and pulse data.") async def hotnow_trending( city: str = "Savannah", neighborhood: str | None = None, category: str | None = None, limit: int = 10, ) -> str: """Read from Redis sorted sets for sub-millisecond response.""" # ... Redis ZREVRANGE trending:{city}:{category} ... @mcp.tool(description="Get neighborhood definitions and venue counts for Savannah metro area.") async def hotnow_neighborhood(city: str = "Savannah") -> str: """Return GeoJSON boundaries + venue counts per neighborhood.""" # ... PostGIS ST_Within queries ... # ... other tools ... if __name__ == "__main__": from fastmcp.server.http import create_streamable_http_app import uvicorn app = create_streamable_http_app(server=mcp, streamable_http_path="/mcp") uvicorn.run(app, host="127.0.0.1", port=8901) ``` #### Systemd Service File: `/etc/systemd/system/hotnow-mcp.service` ```ini [Unit] Description=HotNow Savannah MCP Server After=postgresql.service redis-server.service network-online.target Wants=network-online.target [Service] Type=simple User=root WorkingDirectory=/opt/hotnow-mcp Environment=PATH=/opt/hotnow-mcp/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Environment=PYTHONUNBUFFERED=1 EnvironmentFile=/root/hotnow-api/.env ExecStart=/opt/hotnow-mcp/venv/bin/python3 /opt/hotnow-mcp/server.py Restart=always RestartSec=3 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ``` #### Caddy Route Addition Add to `/etc/caddy/Caddyfile`: ``` mcp.hotnow.io { reverse_proxy 127.0.0.1:8901 header Access-Control-Allow-Origin * header Access-Control-Allow-Methods "GET, POST, OPTIONS" } ``` #### Hermes Agent Integration In `~/.hermes/hermes.yaml`: ```yaml tools: mcp_servers: - name: hotnow-savannah url: https://mcp.hotnow.io/mcp transport: streamable-http ``` --- ### 2.4 Reddit Flywheel Automation **Problem:** Paid user acquisition is expensive ($5-15/install). Savannah has active subreddits with 15K+ SCAD students plus locals. Free community-driven growth is proven by Locale-NYC's success. **Solution:** Automated, scheduled content posting to Savannah subreddits with AI-generated summaries, event CTA links, and UTM tracking. #### Target Subreddits | Subreddit | Subscribers | Content Type | Posting Frequency | |-----------|-------------|-------------|-------------------| | r/savannah | ~45K | General local events | Daily | | r/savannahga | ~8K | Savannah metro | 3x/week | | r/scad | ~12K | Student events + nightlife | 3x/week (during semester) | #### Post Templates Daily "What's Hot Tonight in Savannah" post: ```markdown **What's Hot Tonight in Savannah -- {{ date }}** Here's what's happening according to real-time signals: 🔥 **TRENDING** - {{ top_3_events_by_trending_score }} 🎵 **Live Music Tonight** - {{ live_music_events }} 🍽️ **Food & Drink** - {{ food_events_with_specials }} 🎨 **Arts & Culture** - {{ arts_events }} [View all events on HotNow](https://hotnow.io/savannah?utm_source=reddit&utm_medium=social&utm_campaign=savannah_daily&utm_content={{ date_iso }}) *HotNow tracks what's actually happening right now -- not what Yelp reviewed 3 years ago.* ``` #### Automation Architecture ``` +------------------+ Every 6 hours +------------------+ | PostgreSQL | -----------------> | Reddit Worker | | (events table) | | (PRAW + cron) | +------------------+ +------------------+ | v +------------------+ | AI Summarizer | | (deepseek-v4-pro)| +------------------+ | v +------------------+ | Reddit API | | (PRAW library) | | -> r/savannah | | -> r/scad | +------------------+ ``` #### Implementation ```python #!/usr/bin/env python3 """Reddit Flywheel -- automated content posting for HotNow Savannah.""" import os import json import praw import asyncpg from datetime import datetime, timedelta from openai import OpenAI # AI client via admin-ai (LiteLLM) client = OpenAI( base_url="https://admin-ai.itpropartner.com/v1", api_key=os.environ["LITELLM_API_KEY"], ) REDDIT_SUBREDDITS = { "savannah": {"freq": "daily", "type": "daily_whats_hot"}, "scad": {"freq": "3x_week", "type": "student_events"}, "savannahga": {"freq": "3x_week", "type": "daily_whats_hot"}, } async def generate_daily_post(subreddit: str) -> str: """Fetch top events from DB, generate AI summary, format post.""" conn = await asyncpg.connect(os.environ["DATABASE_URL"]) # Fetch trending events events = await conn.fetch(""" SELECT e.title, e.description, e.start_time, v.name as venue_name, e.trending_score, e.category FROM events e JOIN venues v ON e.venue_id = v.id WHERE v.city = 'Savannah' AND e.start_time >= NOW() AND e.start_time < NOW() + INTERVAL '24 hours' AND e.is_active = true ORDER BY e.trending_score DESC LIMIT 20 """) await conn.close() # Generate AI summary event_summaries = format_events_for_ai(events) ai_prompt = f"""Write a friendly, Gen-Z-coded Reddit post about tonight's events in Savannah. Use these events: {event_summaries} Format: markdown. Include emojis naturally. Keep it helpful, not salesy. Add a link to hotnow.io/savannah with UTM params at the bottom. Max 800 characters.""" response = client.chat.completions.create( model="deepseek-v4-pro", messages=[{"role": "user", "content": ai_prompt}], max_tokens=600, ) return response.choices[0].message.content def post_to_reddit(subreddit: str, content: str) -> str: """Post content to specified subreddit using PRAW.""" reddit = praw.Reddit( client_id=os.environ["REDDIT_CLIENT_ID"], client_secret=os.environ["REDDIT_CLIENT_SECRET"], user_agent="HotNow/1.0 (by /u/hotnow_app)", username=os.environ.get("REDDIT_USERNAME", "hotnow_app"), password=os.environ.get("REDDIT_PASSWORD"), ) sub = reddit.subreddit(subreddit) today = datetime.now().strftime("%A, %B %d") title = f"What's Hot Tonight in Savannah -- {today}" post = sub.submit(title=title, selftext=content, flair_id=None) return post.id ``` #### Cron Schedule ``` # /etc/cron.d/hotnow-reddit 0 9,14,18 * * * root /root/hotnow-api/venv/bin/python3 /root/hotnow-api/reddit_worker.py >> /var/log/hotnow-reddit.log 2>&1 ``` | Time | Action | Target | |------|--------|--------| | 09:00 ET | Daily "What's Hot Today" | r/savannah | | 14:00 ET | Afternoon update | r/scad, r/savannahga | | 18:00 ET | Evening "Tonight" post | r/savannah | #### UTM Tracking Convention ``` https://hotnow.io/savannah?utm_source=reddit&utm_medium=social&utm_campaign=savannah_daily&utm_content=2026-08-11_r_savannah ``` Tracked in `discover_events` table via `source_context = 'reddit_flywheel'`. #### Content Calendar (First 2 Weeks) | Day | r/savannah Post | r/scad Post | |-----|----------------|-------------| | Mon | "This Week in Savannah" -- weekly preview | "SCAD Week Ahead" -- student events | | Tue | "Taco Tuesday + Trivia Night Roundup" | Skip (low engagement) | | Wed | "Midweek Music -- Live Shows Tonight" | "Hump Day Happenings -- student nightlife" | | Thu | "Weekend Preview -- What's Already Buzzing" | "Thirsty Thursday -- drink specials near campus" | | Fri | "What's Hot Tonight in Savannah" (weekend edition) | "Weekend Kickoff -- parties, shows, events" | | Sat | "Saturday Night Live -- music, clubs, nightlife" | Skip (students out) | | Sun | "Sunday Funday -- brunch, markets, chill events" | "Sunday Scaries -- chill study break spots" | --- ### 2.5 SCAD Ambassador Program System **Problem:** Savannah has 15,000+ SCAD students who are the ideal early-adopter demographic -- Gen Z, discovery-driven, social, always looking for what's happening. Beli proved campus ambassador programs work (30M reviews, 80% users under 35). **Solution:** SCAD ambassador portal with student verification, referral tracking, incentives, and content contribution system. #### Ambassador Portal Features | Feature | Description | Tech | |---------|-------------|------| | **SCAD Email Verification** | `*@scad.edu` domain validation + magic link | FastAPI auth + Resend | | **Referral Dashboard** | Track referrals, earned credits, leaderboard | React PWA component | | **Content Submission** | Submit venues, events, photos, reviews | API + admin review queue | | **Social Share Tracking** | Track shares to Instagram/TikTok with attribution | UTM + Redis counters | | **Incentive Credits** | Pro credits for contributions (see table below) | DB transaction + Stripe coupon | #### Incentive System | Action | Credits Earned | Cap (per week) | |--------|---------------|----------------| | Verify SCAD email | 1 month Pro free | One-time | | Submit new venue (approved) | 1 month Pro free | 5 | | Submit event (approved) | 2 weeks Pro free | 10 | | Share to Instagram (with #hotnowsavannah) | 1 week Pro free | 3 | | Refer a friend (friend signs up) | 1 month Pro free each | 10 | | Report outdated event | 1 week Pro free | 5 | | Top 10 weekly leaderboard | 1 month Concierge | 1 winner | Credits implemented as Stripe coupons applied to subscription: `ambassador_credits_{user_id}` with amount equal to monthly Pro cost multiplied by credits earned. #### Verification Flow ``` 1. User clicks "SCAD Student? Get Pro Free" on PWA 2. Enters @scad.edu email in verification form 3. System sends magic link to that email via Resend 4. User clicks link -> verifies ownership 5. System checks: email domain == "scad.edu" && not previously verified 6. If pass: create ambassador profile, grant 1-month Pro credit 7. Redirect to ambassador dashboard ``` #### Database Tables ```sql CREATE TABLE ambassadors ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, scad_email VARCHAR(255) NOT NULL, verification_status VARCHAR(20) DEFAULT 'pending', -- pending, verified, revoked verified_at TIMESTAMPTZ, total_referrals INTEGER DEFAULT 0, total_submissions INTEGER DEFAULT 0, total_credits_earned INTEGER DEFAULT 0, current_streak_weeks INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE ambassador_actions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ambassador_id UUID REFERENCES ambassadors(id) ON DELETE CASCADE, action_type VARCHAR(50) NOT NULL, -- referral, submission, share, report credits_earned INTEGER DEFAULT 0, reference_id UUID, -- event_id, venue_id, or referred user_id metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ); ``` #### Ambassador Dashboard Component Route: `app.hotnow.io/ambassador` (auth-gated) SvelteKit page with: - Stats cards: credits balance, referrals, submissions, rank - Quick actions: submit venue, submit event, share to social - Leaderboard: top 10 ambassadors this week - Activity feed: recent submissions and their approval status - Pro status: days remaining, credits history --- ### 2.6 Neighborhood/Corridor Browsing (Locale-NYC Pattern) **Problem:** Users don't always want "what's near me" -- they want "what's happening on River Street" or "what's good in Starland." Locale-NYC proves corridor/neighborhood browsing drives engagement. **Solution:** PostGIS-powered neighborhood browsing with GeoJSON boundaries and corridor "strip" queries. #### Savannah Neighborhood GeoJSON Definitions Stored in `neighborhoods` table with `boundary` column as PostGIS `GEOGRAPHY(POLYGON, 4326)`. | Neighborhood | Key Streets | Approx Center | |-------------|-------------|---------------| | Historic District | Broughton St, River St, Bay St, Congress St | 32.0809, -81.0912 | | Starland District | Bull St, 41st St, Whitaker St | 32.0517, -81.0988 | | Midtown | Abercorn St, DeRenne Ave | 32.0180, -81.1112 | | Tybee Island | Butler Ave, Strand Ave | 32.0002, -80.8456 | | Pooler | Pooler Pkwy, US-80 | 32.1155, -81.2472 | | SCAD Campus Area | Montgomery St, W Boundary St | 32.0735, -81.0980 | | Victorian District | Bull St, Park Ave | 32.0640, -81.0950 | #### Spatial Queries **Neighborhood query: "What's happening in Starland tonight?"** ```sql SELECT e.*, v.name as venue_name, ST_Distance(v.location, n.boundary::geometry) as distance_to_boundary FROM events e JOIN venues v ON e.venue_id = v.id JOIN neighborhoods n ON ST_Within(v.location::geometry, n.boundary::geometry) WHERE n.slug = 'starland' AND e.start_time >= NOW() AND e.start_time < NOW() + INTERVAL '24 hours' AND e.is_active = true ORDER BY e.trending_score DESC; ``` **Corridor query: "What's on River Street?"** ```sql -- Create a 250m buffer around River Street linestring WITH corridor AS ( SELECT ST_Buffer( ST_GeomFromText('LINESTRING(-81.0940 32.0810, -81.0870 32.0800)', 4326)::geography, 250 -- 250m buffer )::geometry AS geom ) SELECT e.*, v.name as venue_name FROM events e JOIN venues v ON e.venue_id = v.id JOIN corridor c ON ST_Within(v.location::geometry, c.geom) ORDER BY e.trending_score DESC; ``` #### API Endpoint Additions ``` GET /api/v1/savannah/neighborhoods -> List neighborhoods with venue counts and boundaries GET /api/v1/savannah/neighborhood/:slug -> Events in neighborhood, paginated, with trending scores GET /api/v1/savannah/corridor?street=broughton&city=savannah -> Events along a street corridor (250m buffer) ``` #### Map UI: Clustering by Neighborhood Leaflet.js implementation: ```javascript // Cluster markers by neighborhood polygon containment const neighborhoodLayers = {}; neighborhoods.forEach(n => { neighborhoodLayers[n.slug] = L.layerGroup(); neighborhoodLayers[n.slug].addTo(map); }); // Assign each venue marker to its neighborhood layer venues.forEach(v => { const marker = createVenueMarker(v); const hood = findContainingNeighborhood(v.lat, v.lng); if (hood) { marker.addTo(neighborhoodLayers[hood.slug]); } }); // Neighborhood toggle controls L.control.layers(null, neighborhoodLayers, {collapsed: false}).addTo(map); ``` --- ### 2.7 City-Launch Playbook Automation **Problem:** Seeding a new city with 200-300 venues manually takes 1-2 weeks of dedicated work. This must be repeatable for each new city launch (Charleston, Atlanta, etc.). **Solution:** Automated venue ingestion pipeline with AI-assisted curation. #### Ingestion Pipeline ``` +------------------+ +------------------+ +------------------+ | Yelp Fusion API | | Google Places API | | Eventbrite API | +--------+---------+ +--------+---------+ +--------+---------+ | | | v v v +------------------------------------------------------------------+ | Venue Ingestion Worker | | - Query "bars restaurants Savannah GA" from each source | | - Dedup by name + address fuzzy match | | - Geocode addresses via Nominatim (free, no API key) | | - Assign neighborhood via PostGIS ST_Within | | - Enrich with category, hours, social links | +------------------------------------------------------------------+ | v +------------------------------------------------------------------+ | AI Curation Worker | | - For each ingested venue: generate tags, description, vibe | | - Rate quality (1-5) based on completeness + relevance | | - Flag high-quality venues for admin review | | - Suggest category and venue_type | +------------------------------------------------------------------+ | v +------------------------------------------------------------------+ | Admin Review Queue | | POST /admin/review/:id -> approve / edit / reject | | Dashboard URL: admin.hotnow.io/review | +------------------------------------------------------------------+ | v +------------------------------------------------------------------+ | PostgreSQL (venues table) | +------------------------------------------------------------------+ ``` #### Seed Scraper Script ```python #!/usr/bin/env python3 """city_seed.py -- Ingest seed venues for a new HotNow city.""" import asyncio import json import os import asyncpg import httpx from geopy.geocoders import Nominatim from shapely.geometry import Point import geoalchemy2 YELP_API_KEY = os.environ["YELP_FUSION_API_KEY"] GOOGLE_PLACES_KEY = os.environ["GOOGLE_PLACES_API_KEY"] EVENTBRITE_TOKEN = os.environ["EVENTBRITE_API_TOKEN"] DEFAULT_CATEGORIES = ["bars", "restaurants", "musicvenues", "nightlife", "arts"] async def fetch_yelp_venues(city: str, state: str, category: str) -> list[dict]: """Fetch venues from Yelp Fusion API.""" url = "https://api.yelp.com/v3/businesses/search" headers = {"Authorization": f"Bearer {YELP_API_KEY}"} venues = [] for offset in range(0, 1000, 50): # Yelp caps at 1000 results params = { "location": f"{city}, {state}", "categories": category, "limit": 50, "offset": offset, } async with httpx.AsyncClient() as client: resp = await client.get(url, headers=headers, params=params) if resp.status_code != 200: break data = resp.json() venues.extend(data.get("businesses", [])) if len(data.get("businesses", [])) < 50: break return venues async def dedup_and_insert(venues: list[dict], city: str) -> int: """Dedup by name + address fuzzy match, insert into venues table.""" conn = await asyncpg.connect(os.environ["DATABASE_URL"]) inserted = 0 for v in venues: name = v.get("name", "") address = " ".join(v.get("location", {}).get("display_address", [])) lat = v.get("coordinates", {}).get("latitude") lng = v.get("coordinates", {}).get("longitude") # Check for duplicates existing = await conn.fetchrow(""" SELECT id FROM venues WHERE name ILIKE $1 AND address ILIKE $2 AND city = $3 LIMIT 1 """, name, f"%{address}%", city) if existing: continue await conn.execute(""" INSERT INTO venues ( name, description, venue_type, address, city, state, postal_code, location, phone, website, source, source_venue_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, ST_SetSRID(ST_MakePoint($8, $9), 4326)::geography, $10, $11, 'yelp_fusion', $12 ) """, name, v.get("categories", [{}])[0].get("title", ""), map_yelp_to_venue_type(v.get("categories", [])), address, city, v.get("location", {}).get("state", "GA"), v.get("location", {}).get("zip_code", ""), lng, lat, v.get("display_phone", ""), v.get("url", ""), v.get("id", ""), ) inserted += 1 await conn.close() return inserted async def ai_curate_venues(city: str) -> None: """Run AI curation on un-reviewed ingested venues.""" conn = await asyncpg.connect(os.environ["DATABASE_URL"]) venues = await conn.fetch(""" SELECT id, name, description, venue_type, address FROM venues WHERE city = $1 AND source != 'manual' AND reviewed_by_admin = false LIMIT 50 """, city) for v in venues: # Call deepseek-v4-pro via admin-ai to generate: # - polished description (1-2 sentences) # - suggested tags (array) # - vibe rating (1-5) # - suggested category corrections ai_result = await generate_venue_metadata(v["name"], v["description"]) await conn.execute(""" UPDATE venues SET description = $1, tags = $2, metadata = jsonb_set(COALESCE(metadata, '{}'), '{ai_curated}', 'true'), reviewed_by_admin = ($3 >= 4) -- auto-approve high-quality WHERE id = $4 """, ai_result["description"], ai_result["tags"], ai_result["quality_score"], v["id"]) await conn.close() ``` #### Launch Checklist Automation | Check | Script/Query | Threshold | |-------|-------------|-----------| | Total venues | `SELECT COUNT(*) FROM venues WHERE city='Savannah'` | >=200 | | Venues with descriptions | `SELECT COUNT(*) FROM venues WHERE city='Savannah' AND description IS NOT NULL` | >=80% | | Venues with geolocation | `SELECT COUNT(*) FROM venues WHERE city='Savannah' AND location IS NOT NULL` | >=95% | | Venues with hours | `SELECT COUNT(*) FROM venues WHERE city='Savannah' AND hours IS NOT NULL` | >=60% | | Active events next 7 days | `SELECT COUNT(*) FROM events WHERE venue_id IN (...) AND start_time > NOW() AND start_time < NOW() + INTERVAL '7 days'` | >=50 | | Neighborhood coverage | `SELECT n.slug, COUNT(v.id) FROM neighborhoods n LEFT JOIN venues v ON ST_Within(...) GROUP BY n.slug` | All hoods have >=5 venues | | Trending scores populated | Redis `ZCARD trending:savannah:all` | >=50 | --- ## 3. Data Model ### 3.1 Full PostgreSQL Schema #### Enums ```sql CREATE TYPE event_category AS ENUM ( 'music', 'food_drink', 'arts_culture', 'nightlife', 'sports', 'family', 'pop_up', 'other' ); CREATE TYPE venue_type AS ENUM ( 'bar', 'restaurant', 'club', 'theater', 'park', 'gallery', 'pop_up', 'cafe', 'hotel', 'other' ); CREATE TYPE busy_level AS ENUM ('quiet', 'moderate', 'busy', 'packed', 'unknown'); CREATE TYPE subscription_tier AS ENUM ('explorer', 'pro', 'concierge'); CREATE TYPE subscription_status AS ENUM ('active', 'past_due', 'canceled', 'inactive'); CREATE TYPE auth_provider AS ENUM ('email', 'google', 'apple'); CREATE TYPE discover_action AS ENUM ('view', 'click', 'save', 'share', 'check_in'); CREATE TYPE feature_type AS ENUM ('featured_placement', 'event_boost'); CREATE TYPE feature_status AS ENUM ('active', 'expired', 'canceled'); ``` #### Tables **neighborhoods** (new -- Savannah metro boundaries) ```sql CREATE TABLE neighborhoods ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, slug VARCHAR(100) UNIQUE NOT NULL, city VARCHAR(100) NOT NULL DEFAULT 'Savannah', state VARCHAR(2) NOT NULL DEFAULT 'GA', boundary GEOGRAPHY(POLYGON, 4326) NOT NULL, center GEOGRAPHY(POINT, 4326), description TEXT, cover_image_url VARCHAR(500), sort_order INTEGER DEFAULT 0, is_active BOOLEAN DEFAULT true, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_neighborhoods_boundary ON neighborhoods USING GIST (boundary); CREATE INDEX idx_neighborhoods_city ON neighborhoods (city, state); ``` **venues** (extending existing schema with neighborhood FK) ```sql ALTER TABLE venues ADD COLUMN neighborhood_id UUID REFERENCES neighborhoods(id); ALTER TABLE venues ADD COLUMN metadata JSONB DEFAULT '{}'; CREATE INDEX idx_venues_neighborhood ON venues (neighborhood_id); -- Existing columns from hotnow-phase1.md schema: -- id, name, description, venue_type, address, city, state, postal_code, -- location (GEOGRAPHY), geo_json, phone, website, social_links, hours, -- cover_image_url, media_urls, trending_score, current_busy_level, -- owner_user_id, source, source_venue_id, created_at, updated_at ``` **events** (as defined in hotnow-phase1.md, no changes) ```sql -- id, title, description, category, start_time, end_time, timezone, -- venue_id (FK), cover_image_url, media_urls, price_info, ticket_url, -- source, source_event_id, source_url, trending_score, popularity_pulse, -- social_mention_count, search_volume_24h, check_in_count, -- ai_sentiment_score, tags, is_active, reviewed_by_admin, created_at, updated_at ``` **users** (as defined in hotnow-phase1.md) ```sql -- id, email, display_name, avatar_url, password_hash, auth_provider, -- auth_provider_id, email_verified, tier (subscription_tier), -- stripe_customer_id, subscription_status, subscription_expires_at, -- home_city, home_location (GEOGRAPHY), preferred_categories, -- preferred_radius_km, notification_prefs, push_subscription, -- is_business, is_admin, last_active_at, created_at ``` **discover_events** (as defined in hotnow-phase1.md) ```sql -- id, event_id (FK), user_id, action (discover_action), -- source_context, created_at ``` **business_features** (as defined in hotnow-phase1.md) ```sql -- id, venue_id (FK), feature_type, status, starts_at, ends_at, -- stripe_payment_id, amount_paid_cents, metadata, created_at ``` **ambassadors** (new -- SCAD program) ```sql CREATE TABLE ambassadors ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, email VARCHAR(255) NOT NULL, school VARCHAR(100) DEFAULT 'SCAD', verification_status VARCHAR(20) DEFAULT 'pending', verified_at TIMESTAMPTZ, total_referrals INTEGER DEFAULT 0, total_submissions INTEGER DEFAULT 0, total_credits_earned INTEGER DEFAULT 0, current_streak_weeks INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_ambassadors_user ON ambassadors (user_id); CREATE INDEX idx_ambassadors_status ON ambassadors (verification_status); ``` **ambassador_actions** (new) ```sql CREATE TABLE ambassador_actions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ambassador_id UUID REFERENCES ambassadors(id) ON DELETE CASCADE, action_type VARCHAR(50) NOT NULL, credits_earned INTEGER DEFAULT 0, reference_id UUID, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_ambassador_actions_amb ON ambassador_actions (ambassador_id, created_at DESC); ``` **reddit_posts** (new -- track flywheel posts) ```sql CREATE TABLE reddit_posts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subreddit VARCHAR(100) NOT NULL, post_type VARCHAR(50) NOT NULL, -- daily_whats_hot, student_events, weekend_preview reddit_post_id VARCHAR(50), title TEXT NOT NULL, content TEXT, url VARCHAR(500), utm_params JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_reddit_posts_date ON reddit_posts (subreddit, created_at DESC); ``` **seed_ingestion_log** (new -- track city seed pipeline) ```sql CREATE TABLE seed_ingestion_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), city VARCHAR(100) NOT NULL, source VARCHAR(50) NOT NULL, -- yelp_fusion, google_places, eventbrite, manual venues_found INTEGER DEFAULT 0, venues_inserted INTEGER DEFAULT 0, venues_skipped INTEGER DEFAULT 0, errors TEXT[], completed_at TIMESTAMPTZ DEFAULT NOW() ); ``` ### 3.2 PostGIS Spatial Schema ```sql -- Enable extensions CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Spatial indexes CREATE INDEX idx_venues_location_gist ON venues USING GIST (location); CREATE INDEX idx_neighborhoods_boundary_gist ON neighborhoods USING GIST (boundary); CREATE INDEX idx_users_home_location_gist ON users USING GIST (home_location); -- Spatial helper function: find neighborhood for a point CREATE OR REPLACE FUNCTION find_neighborhood(lat DOUBLE PRECISION, lng DOUBLE PRECISION, city_name VARCHAR DEFAULT 'Savannah') RETURNS UUID AS $$ DECLARE hood_id UUID; BEGIN SELECT n.id INTO hood_id FROM neighborhoods n WHERE n.city = city_name AND ST_Within( ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography, n.boundary ) LIMIT 1; RETURN hood_id; END; $$ LANGUAGE plpgsql; -- Spatial helper: venues within radius CREATE OR REPLACE FUNCTION venues_within_radius( lat DOUBLE PRECISION, lng DOUBLE PRECISION, radius_km DOUBLE PRECISION DEFAULT 10.0 ) RETURNS TABLE ( id UUID, name VARCHAR, distance_meters DOUBLE PRECISION ) AS $$ BEGIN RETURN QUERY SELECT v.id, v.name, ST_Distance(v.location, ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography) as distance_meters FROM venues v WHERE ST_DWithin(v.location, ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography, radius_km * 1000) ORDER BY distance_meters; END; $$ LANGUAGE plpgsql; -- Full-text search on events + venues ALTER TABLE events ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', COALESCE(title, '')), 'A') || setweight(to_tsvector('english', COALESCE(description, '')), 'B') || setweight(to_tsvector('english', COALESCE(array_to_string(tags, ' '), '')), 'C') ) STORED; CREATE INDEX idx_events_search ON events USING GIN (search_vector); ALTER TABLE venues ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', COALESCE(name, '')), 'A') || setweight(to_tsvector('english', COALESCE(description, '')), 'B') ) STORED; CREATE INDEX idx_venues_search ON venues USING GIN (search_vector); ``` ### 3.3 Redis Key Schema (Extended) ``` # Trending (from Phase 1 doc, extended with Savannah neighborhoods) trending:savannah:all Sorted Set {event_id: hot_score} trending:savannah:music Sorted Set {event_id: hot_score} trending:savannah:food_drink Sorted Set {event_id: hot_score} trending:savannah:arts_culture Sorted Set {event_id: hot_score} trending:savannah:nightlife Sorted Set {event_id: hot_score} trending:savannah:pop_up Sorted Set {event_id: hot_score} trending:savannah:starland Sorted Set {event_id: hot_score} trending:savannah:historic_district Sorted Set {event_id: hot_score} # ... one per neighborhood # Pulse (per-event real-time activity, 15-min TTL) pulse:{event_id}:views Sorted Set {timestamp_ms: count} pulse:{event_id}:clicks Sorted Set {timestamp_ms: count} pulse:{event_id}:saves Sorted Set {timestamp_ms: count} pulse:{event_id}:shares Sorted Set {timestamp_ms: count} # Sessions session:{user_id}:{device_id} Hash {refresh_token, expires_at, device_info} # Rate limiting ratelimit:{ip}:{endpoint} String {counter} TTL: 1h # Geo-index cache (5-min TTL) geo:{lat}:{lng}:{radius_km} Set {event_ids} # Ambassador ambassador:leaderboard:{week} Sorted Set {user_id: points} ambassador:{user_id}:credits String {total_credits} # Reddit flywheel reddit:last_post:{subreddit} String {timestamp} TTL: 24h # City launch status city_launch:{city}:checklist Hash {check_name: value} ``` --- ## 4. API Surface ### 4.1 REST API Endpoints (api.hotnow.io/v1) #### Events | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/events/trending` | Optional | Trending events by city/neighborhood/category | | `GET` | `/events/nearby` | Optional | Events within radius of lat/lng | | `GET` | `/events/:id` | Optional | Full event detail with venue + pulse data | | `GET` | `/events/search` | Optional | Full-text search (tsvector) across events | | `POST` | `/events/submit` | User | Submit event to admin review queue | | `POST` | `/events/:id/save` | User | Bookmark event | | `POST` | `/events/:id/check-in` | User | Check in (feeds trending pulse) | | `GET` | `/events/:id/pulse` | Optional | Real-time activity data for event | #### Venues | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/venues/nearby` | Optional | Venues within radius | | `GET` | `/venues/:id` | Optional | Venue detail + upcoming events | | `GET` | `/venues/search` | Optional | Full-text search across venues | | `GET` | `/venues/:id/busy` | Optional | Current busy level estimate | #### Savannah-Specific | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/savannah/neighborhoods` | None | List neighborhoods with counts | | `GET` | `/savannah/neighborhood/:slug` | None | Events in neighborhood | | `GET` | `/savannah/corridor` | None | Events along named street (query: `?street=broughton`) | | `GET` | `/savannah/stats` | Admin | City-level stats (venues, events, users, MAU) | #### Discovery & AI | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/discover/feed` | Optional | Personalized feed (AI if Pro, trending if free) | | `GET` | `/discover/recommendations` | Pro | AI-powered "Best Right Now" picks | | `POST` | `/discover/action` | Optional | Log view/click/save for trending signals | | `GET` | `/ai/events` | None | AI-optimized compact event list (<500 tokens) | | `GET` | `/ai/trending` | None | AI-optimized trending list | #### Auth | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `POST` | `/auth/register` | None | Create account (email + password) | | `POST` | `/auth/login` | None | Login -> JWT access + refresh token | | `POST` | `/auth/refresh` | Refresh | Get new access token | | `POST` | `/auth/logout` | Access | Revoke refresh token | | `POST` | `/auth/oauth/google` | None | Google OAuth login | | `POST` | `/auth/oauth/apple` | None | Apple Sign In | | `GET` | `/auth/me` | Access | Current user profile | #### SCAD Ambassador | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `POST` | `/ambassador/verify` | User | Submit SCAD email for verification | | `GET` | `/ambassador/status` | User | Ambassador status, credits, stats | | `GET` | `/ambassador/leaderboard` | None | Top 10 ambassadors this week | | `POST` | `/ambassador/submit-venue` | Ambassador | Submit new venue | | `POST` | `/ambassador/submit-event` | Ambassador | Submit new event | | `GET` | `/ambassador/activity` | Ambassador | Recent actions + approval status | #### Billing | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/billing/plans` | None | Available subscription tiers | | `POST` | `/billing/subscribe` | User | Create Stripe checkout session | | `GET` | `/billing/portal` | User | Redirect to Stripe Customer Portal | | `POST` | `/billing/webhook` | Stripe | Stripe webhook receiver (no auth, verified by signature) | #### Admin (Internal) | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `GET` | `/admin/review-queue` | Admin | Events/venues pending review | | `POST` | `/admin/review/:id` | Admin | Approve/edit/reject submission | | `POST` | `/admin/recalculate-trending` | Admin | Force trend recalculation | | `GET` | `/admin/stats` | Admin | Platform metrics dashboard | | `GET` | `/admin/city-launch-checklist/:city` | Admin | Seed completeness check | | `POST` | `/admin/seed-city` | Admin | Trigger seed ingestion pipeline | ### 4.2 MCP Tools (mcp.hotnow.io) | Tool | Parameters | Returns | |------|-----------|---------| | `hotnow_search_events` | `query`, `category?`, `city`, `date_from?`, `date_to?`, `limit` | JSON array of events with venue info | | `hotnow_get_venue` | `venue_id` or `venue_name` | Full venue detail + upcoming events | | `hotnow_trending` | `city`, `neighborhood?`, `category?`, `limit` | JSON array ranked by trending_score | | `hotnow_neighborhood` | `city` | GeoJSON boundaries + venue counts per neighborhood | | `hotnow_recommendations` | `city`, `preferences`, `lat`, `lng` | AI-ranked personalized recommendations | | `hotnow_venue_search` | `query`, `city`, `venue_type?`, `limit` | JSON array of matching venues | | `hotnow_health` | (none) | Service health status + DB/Redis connectivity | ### 4.3 Auth Flow ``` 1. User registers/logs in -> receives: - access_token (JWT, HS256, 15-min expiry, contains: sub, tier, is_admin) - refresh_token (opaque, 30-day expiry, stored in Redis: session:{user_id}:{device_id}) 2. Every API call: Authorization: Bearer 3. Access token expires -> POST /auth/refresh { "refresh_token": "..." } -> new access_token + rotated refresh_token 4. Logout -> delete Redis session key, revoke refresh token 5. Anonymous users: session_id (UUID v4, stored in localStorage) - Limited rate: 100 requests/hour - Cannot save/bookmark (no persistence without account) ``` ### 4.4 Rate Limits | Tier | Requests/Hour | AI Recs | Save Limit | Search Limit | |------|--------------|---------|------------|-------------| | Anonymous | 100 | N/A | 0 | 10/hr | | Explorer (Free) | 300 | 10/month | 50 | 50/hr | | Pro ($4.99/mo) | 1000 | Unlimited | Unlimited | 200/hr | | Concierge ($19.99/mo) | 5000 | Unlimited + Concierge Chat | Unlimited | 500/hr | | Ambassador | 500 | 50/month (or per credits) | 50 | 100/hr | | Business | 500 | N/A | N/A | N/A | | Admin | Unlimited | N/A | N/A | N/A | --- ## 5. Ranking Algorithm ### 5.1 Complete Formula ``` HOT_SCORE(event_id, user_context) = ( FRESHNESS(event_id) * W_f (0.30) + VELOCITY(event_id) * W_v (0.25) + SOCIAL_PROOF(event_id) * W_s (0.20) + CONTEXTUAL(event_id, user_context) * W_c (0.15) + MANUAL_BOOST(event_id) * W_m (0.10) ) * CITY_NORMALIZATION(city) * TIME_RAMP(event_id) ``` ### 5.2 Signal Detail #### FRESHNESS (30%) ``` f_created = max(0, 1.0 - hours_since_created / 168) # Linear decay over 7 days f_updated = max(0, 1.0 - hours_since_updated / 48) # Linear decay over 2 days f_checkin = min(1.0, check_ins_last_24h / 20.0) # Cap at 20 check-ins FRESHNESS = 0.4 * f_created + 0.4 * f_updated + 0.2 * f_checkin ``` #### VELOCITY (25%) ```python # Pulse metrics from Redis (2-hour rolling window) views_per_min = redis.zcount(f"pulse:{eid}:views", now-7200, now) / 120 clicks_per_min = redis.zcount(f"pulse:{eid}:clicks", now-7200, now) / 120 saves_per_min = redis.zcount(f"pulse:{eid}:saves", now-7200, now) / 120 # Acceleration: compare current velocity to previous 2-hour window accel = (views_per_min + clicks_per_min * 1.5 + saves_per_min * 2.0) / prev_velocity VELOCITY = clamp(normalize(accel), 0.0, 1.0) ``` #### SOCIAL_PROOF (20%) ```python # Super Search v2 crawl results (cache 15 min) ig_mentions = social_cache.get(f"ig:{venue_name}", 0) tt_mentions = social_cache.get(f"tt:{venue_name}", 0) reddit_mentions = social_cache.get(f"reddit:{venue_name}", 0) SOCIAL_PROOF = ( 0.40 * min(1.0, log1p(ig_mentions) / 5.0) + 0.35 * min(1.0, log1p(tt_mentions) / 4.0) + 0.25 * min(1.0, log1p(reddit_mentions) / 3.0) ) ``` #### CONTEXTUAL (15%) ```python # Weather (OpenWeatherMap API, cache 30 min) weather_score = { "clear": 1.0, "clouds": 0.8, "rain": 0.5, "thunderstorm": 0.2 }.get(current_weather, 0.5) # Time of day match time_scores = { (6, 11): "food_drink", # Breakfast/brunch (11, 14): "food_drink", # Lunch (14, 17): "arts_culture", # Afternoon activities (17, 21): "all", # Evening - everything (21, 2): "nightlife", # Late night (2, 6): "nightlife", # Very late / after-hours } # Day of week boost weekend_boost = 1.2 if current_day in [5, 6] else 1.0 # Friday/Saturday CONTEXTUAL = ( 0.40 * weather_score + 0.30 * category_time_match + 0.15 * weekend_boost + 0.15 * proximity_boost # Distance decay: 1.0 at 0km, 0.1 at 50km ) ``` #### MANUAL_BOOST (10%) ```python # From business_features table feature = get_active_feature(venue_id) if feature.type == "featured_placement": MANUAL_BOOST = 0.85 # Strong boost elif feature.type == "event_boost": MANUAL_BOOST = 0.50 # Event-specific boost else: MANUAL_BOOST = 0.0 ``` ### 5.3 City Normalization (Savannah-Specific) ``` CITY_NORMALIZATION = log10(active_users_in_city + 1) / log10(total_events_in_city + 20) ``` For Savannah at launch: - `active_users` = 50 (early adopters) - `total_events` = 150 (seeded) - `CITY_NORMALIZATION` = log10(51) / log10(170) = 1.71 / 2.23 = **0.77** This means Savannah events get a 0.77x multiplier initially -- lower than a mature city, but workable. As user count grows, normalization approaches 1.0. The "+20" floor in the denominator prevents division-by-zero at launch. ### 5.4 Time Ramp ``` TIME_RAMP(event) = 0.5 if hours_until_start > 24 0.5 + 0.5 * (24 - hours_until_start) / 24 if 0 <= hours_until_start <= 24 1.0 - 0.3 * hours_since_start / 4 if hours_since_start <= 4 0.3 if hours_since_start > 4 ``` Peaks at event start time, decays after 4 hours post-start. ### 5.5 Fallback for Sparse Data (Savannah Launch Mode) When social signals are low (common for new city launches): ```python def hot_score_with_fallback(event_id, user_context): social = SOCIAL_PROOF(event_id) velocity = VELOCITY(event_id) # If social + velocity signals are both weak, freshness dominates if social < 0.15 and velocity < 0.15: # Rebalance weights: freshness at 50%, manual at 20% return ( 0.50 * FRESHNESS(event_id) + 0.15 * velocity + 0.10 * social + 0.15 * CONTEXTUAL(event_id, user_context) + 0.10 * MANUAL_BOOST(event_id) ) * CITY_NORMALIZATION * TIME_RAMP(event_id) return full_formula(event_id, user_context) ``` ### 5.6 Ranking Worker Implementation File: `/root/hotnow-api/trend_worker.py` ```python #!/usr/bin/env python3 """ARQ worker: recalculates trending scores every 5 minutes.""" import asyncio import math import asyncpg import redis.asyncio as redis from datetime import datetime, timedelta from arq import cron from arq.connections import RedisSettings async def recalculate_trending(ctx): """Recalculate HOT_SCORE for all active events.""" conn = await asyncpg.connect(ctx["DATABASE_URL"]) r = await redis.from_url(ctx["REDIS_URL"]) events = await conn.fetch(""" SELECT e.id, e.created_at, e.updated_at, e.start_time, e.category, v.id as venue_id, v.city FROM events e JOIN venues v ON e.venue_id = v.id WHERE e.is_active = true AND e.start_time >= NOW() - INTERVAL '24 hours' """) for e in events: score = calculate_score(e, conn, r) await conn.execute( "UPDATE events SET trending_score = $1, updated_at = NOW() WHERE id = $2", score, e["id"] ) # Update Redis sorted sets city = e["city"].lower() category = e["category"] await r.zadd(f"trending:{city}:all", {str(e["id"]): score}) await r.zadd(f"trending:{city}:{category}", {str(e["id"]): score}) await conn.close() await r.close() async def cleanup_old_pulses(ctx): """Remove pulse data older than 6 hours.""" r = await redis.from_url(ctx["REDIS_URL"]) cutoff = (datetime.utcnow() - timedelta(hours=6)).timestamp() keys = await r.keys("pulse:*") for key in keys: await r.zremrangebyscore(key, 0, cutoff) await r.close() class WorkerSettings: redis_settings = RedisSettings.from_dsn(os.environ["REDIS_URL"]) cron_jobs = [ cron(recalculate_trending, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}), cron(cleanup_old_pulses, hour=range(0, 24, 6)), # Every 6 hours ] ``` --- ## 6. Implementation Roadmap ### 6.1 4-Week Sprint Plan (Savannah Launch) #### Phase 0: Foundation -- Week 1 (40 hours) | Day | Task | Hours | Files/Outputs | Dependencies | |-----|------|-------|---------------|-------------| | Mon | DNS + Caddy: Configure hotnow.io subdomains at Cloudflare | 2 | Cloudflare DNS panel, Caddyfile | Domain owned | | Mon | PostgreSQL: Create hotnow DB, run full schema migration | 4 | Migration files in `/root/hotnow-api/migrations/` | PostgreSQL running | | Mon | PostGIS: Install extension, create spatial indexes, neighborhoods table | 3 | Spatial schema SQL | PostgreSQL | | Tue | Redis: Configure key namespace, set up trending key pattern | 2 | Redis config | Redis running | | Tue | PWA shell: Initialize SvelteKit project with Savannah viewport | 6 | `/var/www/hotnow-app/` scaffold | Node.js | | Wed | PWA: Leaflet.js map implementation with OSM tiles | 6 | Map component, marker clustering | SvelteKit | | Wed | PWA: Bottom nav, filter chips, category bar | 4 | UI components | Map done | | Thu | API: Core FastAPI routes (events, venues, trending) | 6 | `/root/hotnow-api/routers/` | Postgres schema | | Thu | API: Auth flow (JWT, refresh, register/login) | 4 | `/root/hotnow-api/auth.py` | Users table | | Fri | Seed data: Build seed scraper for Savannah (Yelp + Google Places) | 8 | `/root/hotnow-api/city_seed.py` | API keys | | Sat | Seed data: Run ingestion, verify 200+ venues in DB | 3 | Verified in psql | Scraper done | | **Subtotal** | | **48h** | | | #### Phase 1: Core Engine -- Week 2 (45 hours) | Day | Task | Hours | Files/Outputs | Dependencies | |-----|------|-------|---------------|-------------| | Mon | Ranking engine: Implement core HOT_SCORE calculator | 6 | `/root/hotnow-api/ranking.py` | Redis + Postgres | | Mon | Ranking: Signal modules (freshness, velocity, social, contextual) | 4 | `/root/hotnow-api/signals/*.py` | Ranking core | | Tue | Ranking: ARQ worker with 5-min cron, Redis population | 4 | `/root/hotnow-api/trend_worker.py` | Ranking done | | Tue | Ranking: Systemd service for ARQ worker | 1 | `/etc/systemd/system/hotnow-worker.service` | ARQ | | Tue | Event aggregator: Eventbrite API connector | 4 | `/root/hotnow-api/aggregators/eventbrite.py` | API key | | Wed | Event aggregator: Ticketmaster API connector | 4 | `/root/hotnow-api/aggregators/ticketmaster.py` | API key | | Wed | Event aggregator: Meetup API connector | 3 | `/root/hotnow-api/aggregators/meetup.py` | API key | | Thu | AI curation: Venue metadata enrichment via deepseek-v4-pro | 5 | `/root/hotnow-api/ai_curator.py` | Venues seeded | | Thu | AI curation: Event description generation pipeline | 3 | Extension of ai_curator.py | Events | | Fri | Testing: End-to-end test of ranking pipeline with seed data | 6 | Test output, score verification | All above | | Fri | PWA: Wire map view to API endpoints (real data) | 5 | Frontend API integration | API running | | **Subtotal** | | **45h** | | | #### Phase 2: User-Facing -- Week 3 (50 hours) | Day | Task | Hours | Files/Outputs | Dependencies | |-----|------|-------|---------------|-------------| | Mon | PWA: Event detail page with cover image, venue, actions | 6 | `src/routes/event/[id]/+page.svelte` | API | | Mon | PWA: Discovery feed (vertical scroll EventCards) | 4 | `src/routes/discover/+page.svelte` | API | | Tue | PWA: "Best Right Now" AI recommendation UI | 5 | AI rec component + API integration | AI curation | | Tue | PWA: User auth (login, register, profile) | 5 | Auth pages + auth store | Auth API | | Wed | PWA: Neighborhood browsing (map clustering + list view) | 6 | Neighborhood components + API | PostGIS | | Wed | PWA: Search with filters (category, date, neighborhood) | 4 | Search page + API | Events | | Thu | Stripe: Subscription checkout flow + webhooks | 5 | Stripe integration in API + PWA | Stripe keys | | Thu | Stripe: Business featured placement + event boost purchase | 3 | Business feature purchase flow | Stripe | | Fri | Structured data: JSON-LD, OG tags, Twitter cards | 4 | SvelteKit components | PWA pages | | Fri | Sitemap generator for search engines | 2 | `/root/hotnow-api/sitemap_generator.py` + cron | Data | | Fri | PWA: Offline support (service worker, IndexedDB cache) | 4 | Service worker, offline helpers | PWA shell | | Sat | Testing: Cross-browser mobile testing (iOS Safari, Chrome Android) | 3 | Test report | PWA done | | **Subtotal** | | **51h** | | | #### Phase 3: Growth & Launch -- Week 4 (40 hours) | Day | Task | Hours | Files/Outputs | Dependencies | |-----|------|-------|---------------|-------------| | Mon | Reddit flywheel: PRAW setup, AI post generator, templates | 5 | `/root/hotnow-api/reddit_worker.py` | Events data | | Mon | Reddit: Cron schedule + content calendar + UTM tracking | 2 | `/etc/cron.d/hotnow-reddit` | Reddit API | | Tue | SCAD ambassador: Verification flow (magic link + domain check) | 5 | Auth + ambassador endpoints | Users table | | Tue | SCAD ambassador: Dashboard (credits, referrals, submissions) | 4 | `src/routes/ambassador/+page.svelte` | Auth | | Wed | SCAD ambassador: Incentive system (Stripe coupon generation) | 3 | Stripe coupon logic | Stripe | | Wed | MCP server: Implement all 7 tools with FastMCP | 6 | `/opt/hotnow-mcp/server.py` | API + Postgres | | Thu | MCP server: Systemd service, Caddy route, testing | 2 | systemd + Caddy config | MCP server | | Thu | City-launch playbook: Automate checklist, seed pipeline script | 4 | Refine city_seed.py, checklist endpoint | Seed data | | Fri | Testing: Full integration test -- map to API to DB to Redis | 4 | Test suite | All above | | Fri | Bug fixes + polish from testing | 4 | Various | Testing | | Sat | Documentation: Deployment runbook, API docs, admin guide | 3 | This document + FastAPI /docs | All above | | Sat | Beta launch: Release to 20-50 SCAD student early users | 2 | Live deployment | All above | | **Subtotal** | | **44h** | | | #### Grand Total: ~188 hours (4 weeks) ### 6.2 Effort Breakdown by Component | Component | Hours | % of Total | |-----------|-------|-----------| | Database + PostGIS | 10 | 5% | | PWA Frontend | 42 | 22% | | REST API Backend | 20 | 11% | | Ranking Engine | 15 | 8% | | Event Aggregators | 11 | 6% | | AI Curation Pipeline | 8 | 4% | | Auth + User System | 12 | 6% | | Stripe Billing | 8 | 4% | | Structured Data Layer | 6 | 3% | | MCP Server | 8 | 4% | | Reddit Flywheel | 7 | 4% | | SCAD Ambassador | 12 | 6% | | City Launch Playbook | 4 | 2% | | Seed Data Pipeline | 11 | 6% | | Testing + QA | 10 | 5% | | Documentation | 4 | 2% | | **Total** | **188** | **100%** | ### 6.3 Critical Path ``` PostgreSQL schema -> Seed data pipeline -> API endpoints -> Ranking engine -> PWA shell -> Map view -> Discovery feed -> Event detail -> Stripe -> Neighborhood browsing -> Reddit flywheel -> SCAD ambassador -> MCP server -> Testing -> Launch ``` Bottleneck risk: Seed data pipeline (depends on Yelp/Google API keys being provisioned). Mitigation: start API key provisioning on Day 0. --- ## 7. Infrastructure & Deployment ### 7.1 DNS Configuration (Cloudflare) ``` A hotnow.io -> 152.53.192.33 (TTL: Auto) A app.hotnow.io -> 152.53.192.33 A api.hotnow.io -> 152.53.192.33 A admin.hotnow.io -> 152.53.192.33 A mcp.hotnow.io -> 152.53.192.33 CNAME www.hotnow.io -> hotnow.io ``` ### 7.2 Caddy Configuration Existing routes in `/etc/caddy/Caddyfile` (lines 359-383) already cover: - `hotnow.io, www.hotnow.io` -> `/var/www/hotnow` - `app.hotnow.io` -> `/var/www/hotnow-app` - `api.hotnow.io` -> reverse_proxy `127.0.0.1:8001` - `admin.hotnow.io` -> `/var/www/hotnow-admin` Add after line 383: ``` # HotNow MCP Server mcp.hotnow.io { reverse_proxy 127.0.0.1:8901 header Access-Control-Allow-Origin * header Access-Control-Allow-Methods "GET, POST, OPTIONS" } ``` ### 7.3 Systemd Services | Service | File | Status | |---------|------|--------| | **hotnow-api** | `/etc/systemd/system/hotnow-api.service` | Running (partial impl) | | **hotnow-worker** | `/etc/systemd/system/hotnow-worker.service` | To create | | **hotnow-mcp** | `/etc/systemd/system/hotnow-mcp.service` | To create | | **super-search** | `/etc/systemd/system/super-search.service` | Running (8899) | | **postgresql** | System package | Running (5432) | | **redis-server** | System package | Running (6379) | #### hotnow-worker.service (ARQ background tasks) ```ini [Unit] Description=HotNow ARQ Worker (Trending, Ingestion, Notifications) After=redis-server.service network-online.target Wants=network-online.target [Service] Type=simple User=root WorkingDirectory=/root/hotnow-api EnvironmentFile=/root/hotnow-api/.env ExecStart=/root/hotnow-api/venv/bin/arq trend_worker.WorkerSettings Restart=always RestartSec=3 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target ``` #### Service Startup Order ```bash systemctl enable hotnow-api hotnow-worker hotnow-mcp systemctl start hotnow-api systemctl start hotnow-worker systemctl start hotnow-mcp ``` ### 7.4 Server Environment File: `/root/hotnow-api/.env` ```bash # Database DATABASE_URL=postgresql://hotnow_app:@localhost:5432/hotnow REDIS_URL=redis://localhost:6379/0 # Auth JWT_SECRET= JWT_REFRESH_SECRET= # Stripe STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... # Super Search v2 SUPER_SEARCH_ENDPOINT=http://127.0.0.1:8899/mcp # AI (admin-ai LiteLLM) LITELLM_API_KEY= LITELLM_BASE_URL=https://admin-ai.itpropartner.com/v1 # External APIs YELP_FUSION_API_KEY= GOOGLE_PLACES_API_KEY= EVENTBRITE_API_TOKEN= TICKETMASTER_API_KEY= MEETUP_API_KEY= # Reddit (PRAW) REDDIT_CLIENT_ID= REDDIT_CLIENT_SECRET= REDDIT_USERNAME=hotnow_app REDDIT_PASSWORD= # Weather (OpenWeatherMap) OPENWEATHER_API_KEY= # Email RESEND_API_KEY= # CORS CORS_ORIGINS=https://app.hotnow.io,https://admin.hotnow.io # Environment ENVIRONMENT=production CITY=Savannah DEBUG=false ``` ### 7.5 Database Backup Strategy | Frequency | Method | Location | Retention | |-----------|--------|----------|-----------| | Hourly | WAL archiving | `/var/lib/postgresql/wal_archive/` | 24h | | Daily | `pg_dump hotnow` | `rclone -> Wasabi S3 (hotnow-backups bucket)` | 30 days | | Weekly | Full dump + schema | `rclone -> Wasabi S3` | 90 days | | Monthly | Full + config files | `rclone -> Wasabi S3` | 12 months | #### Backup Script ```bash #!/bin/bash # /root/hotnow-api/scripts/backup.sh DATE=$(date +%Y-%m-%d_%H%M) BACKUP_DIR=/var/backups/hotnow mkdir -p $BACKUP_DIR # Database dump pg_dump -U hotnow_app -d hotnow -Fc > $BACKUP_DIR/hotnow_$DATE.dump # Redis snapshot redis-cli SAVE cp /var/lib/redis/dump.rdb $BACKUP_DIR/redis_$DATE.rdb # Config backup tar czf $BACKUP_DIR/config_$DATE.tar.gz \ /root/hotnow-api/.env \ /etc/caddy/Caddyfile \ /etc/systemd/system/hotnow-*.service # Upload to Wasabi S3 rclone copy $BACKUP_DIR wasabi:hotnow-backups/daily/$DATE/ # Cleanup old local backups (>3 days) find $BACKUP_DIR -type f -mtime +3 -delete ``` Cron: `0 3 * * * root /root/hotnow-api/scripts/backup.sh` ### 7.6 Restore Procedure ```bash # 1. Stop services systemctl stop hotnow-api hotnow-worker hotnow-mcp # 2. Restore PostgreSQL dropdb -U hotnow_app hotnow createdb -U hotnow_app hotnow pg_restore -U hotnow_app -d hotnow /path/to/hotnow_YYYY-MM-DD_HHMM.dump # 3. Restore Redis systemctl stop redis-server cp /path/to/redis_YYYY-MM-DD_HHMM.rdb /var/lib/redis/dump.rdb systemctl start redis-server # 4. Start services systemctl start hotnow-api hotnow-worker hotnow-mcp # 5. Verify curl https://api.hotnow.io/health curl https://mcp.hotnow.io/health ``` --- ## 8. Integration Points ### 8.1 Super Search v2 Integration | Integration | Endpoint/Pattern | Purpose | |-------------|-----------------|---------| | Social mention crawling | `web_search("venue name Savannah GA")` with time_range filter | Count Instagram/TikTok/Reddit mentions for social proof signal | | Event discovery | `web_search_news("Savannah GA events tonight")` | Discover events not on Eventbrite/Ticketmaster | | Venue enrichment | `web_search("venue name")` + `web_extract(venue_url)` | Enrich venue descriptions, hours, social links | | API health dependency | `health_check()` call before HotNow health endpoint | Propagate Super Search status in HotNow health | | Circuit breaker awareness | `circuit_status()` before bulk operations | Avoid hammering degraded providers during ingest | Super Search endpoint: `http://127.0.0.1:8899/mcp` (Streamable HTTP, FastMCP 4.x) ### 8.2 admin-ai (LiteLLM) Integration | Integration | Model | Purpose | |-------------|-------|---------| | AI curation pipeline | deepseek-v4-pro | Generate venue descriptions, tags, quality ratings | | "Best Right Now" recommendations | deepseek-v4-pro | Personalized event recommendations based on user preferences, weather, time, location | | Concierge chat | deepseek-v4-pro | AI-powered concierge for itinerary building | | Reddit post generation | deepseek-v4-pro | Generate daily "What's Hot Tonight" post content | | Event description polish | deepseek-v4-pro | Clean up and enhance user-submitted event descriptions | Base URL: `https://admin-ai.itpropartner.com/v1` API Key: `LITELLM_API_KEY` in env ### 8.3 Stripe Integration | Integration | Endpoint | Purpose | |-------------|----------|---------| | Consumer subscriptions | Stripe Checkout + Customer Portal | Pro ($4.99/mo) and Concierge ($19.99/mo) | | Business featured placement | Stripe Checkout one-time + recurring | $97/mo featured venue placement | | Event boost | Stripe Checkout one-time | $47/event one-time boost | | Ambassador credits | Stripe Coupons API | Generate discount coupons for ambassador credits | | Webhooks | `POST /billing/webhook` | Handle subscription lifecycle (created, updated, canceled, payment_failed) | ### 8.4 Mapbox / Leaflet.js | Integration | Purpose | |-------------|---------| | Leaflet.js + OpenStreetMap | Primary map tiles (free, no API key, offline-cacheable) | | Mapbox GL JS (optional future) | Satellite imagery, 3D buildings, dark theme tiles | | Leaflet.markercluster | Cluster venue markers by proximity | | Leaflet GeoJSON | Render neighborhood boundaries | Decision: Start with Leaflet.js + OSM tiles (free). Swap to Mapbox when revenue supports it (>$500 MRR). Mapbox free tier: 50K monthly loads. ### 8.5 External API Dependencies | API | Free Tier | Rate Limit | HotNow Usage | |-----|----------|------------|-------------| | Yelp Fusion | 5K calls/day | 500/hr | Seed scraper (one-time per city) | | Google Places | $200/mo credit | Varies | Seed scraper, venue enrichment | | Eventbrite | Free | 1K/hr | Ongoing event ingestion | | Ticketmaster | Free (Discovery API) | 5K/day | Ongoing event ingestion | | Meetup | Free (GraphQL) | Limited | Ongoing event ingestion | | OpenWeatherMap | 1K calls/day free | 60/min | Contextual weather signal | | Reddit (PRAW) | Free | 60 posts/min (OAuth) | Daily flywheel posts | | Resend (email) | 3K emails/mo free | N/A | Auth magic links, notifications | --- ## 9. Testing & QA Strategy ### 9.1 Test Categories | Category | Approach | Coverage Target | Tools | |----------|----------|----------------|-------| | **Unit Tests** | pytest for ranking engine, signal calcs, API models | 80% | pytest, pytest-cov | | **Integration Tests** | httpx async test client against live API | Core flows (auth, trending, search, submit) | pytest-asyncio, httpx | | **Database Tests** | Test against test DB with seed data fixtures | All PostGIS spatial queries, trending calc | pytest-postgresql | | **Redis Tests** | Test against test Redis with mock pulse data | Trending sorted sets, rate limiting, sessions | fakeredis or test instance | | **PWA E2E Tests** | Manual mobile testing | Critical paths (browse -> view event -> save) | iOS Safari, Chrome Android | | **Performance Tests** | Apache Bench / wrk against trending endpoint | <200ms p95 for Redis-backed trending | wrk, ab | | **Load Tests** | Simulate 100 concurrent users browsing map | API handles 100 req/s without degradation | locust | | **Accessibility** | Lighthouse audit | PWA score >90 | Lighthouse CLI | | **Security** | OWASP ZAP basic scan + manual JWT/CORS review | No critical/high findings | ZAP, manual | ### 9.2 Test Data Seed fixture file: `/root/hotnow-api/tests/fixtures/savannah_seed.sql` Contains: - 50 venues across all Savannah neighborhoods - 30 events (various categories, some starting in next 24h) - 5 users (1 admin, 2 pro, 1 explorer, 1 ambassador) - 2 neighborhoods (Historic District, Starland) with GeoJSON boundaries - Redis fixtures: pulse data for 10 events, trending sorted sets ### 9.3 Critical Test Cases | # | Test | Expected | Priority | |---|------|----------|----------| | 1 | Anonymous user sees trending events near Savannah | Events returned with scores, paginated | P0 | | 2 | Pro user gets AI "Best Right Now" picks | Personalized JSON with 5 picks | P0 | | 3 | Ranking engine recalculates scores every 5 min | trending_score column updates, Redis sorted set updates | P0 | | 4 | SCAD email verification works | Magic link sent, account marked verified, 1-month credit issued | P0 | | 5 | Reddit flywheel generates daily post | Markdown post with top events + UTM link output to log | P1 | | 6 | Stripe subscription flow end-to-end | Checkout -> webhook -> tier upgrade -> UI reflects Pro | P1 | | 7 | Neighborhood browsing returns correct events | Starland query returns only Starland venues | P1 | | 8 | Corridor browsing (Broughton St) | Only venues within 250m of Broughton St linestring | P1 | | 9 | SEO: sitemap-savannah.xml accessible | Valid XML with event URLs, lastmod dates | P1 | | 10 | MCP tools return correct JSON | hotnow_trending returns valid ranked event list | P1 | | 11 | Offline PWA: cached events visible without network | Previously loaded events show in discovery feed | P2 | | 12 | Anti-gaming: velocity cap prevents score manipulation | Burst engagement throttled to previous rate | P2 | ### 9.4 Pre-Launch Checklist | Check | Method | Pass Criteria | |-------|--------|---------------| | All systemd services running | `systemctl status hotnow-api hotnow-worker hotnow-mcp` | All "active (running)" | | Health endpoints responding | `curl https://api.hotnow.io/health` | HTTP 200, all services healthy | | SSL valid on all subdomains | `curl -I https://app.hotnow.io` | No cert errors, HSTS header present | | 200+ Savannah venues in DB | `SELECT COUNT(*) FROM venues WHERE city='Savannah'` | >=200 | | 50+ upcoming events | `SELECT COUNT(*) FROM events WHERE start_time > NOW() AND start_time < NOW() + INTERVAL '7 days'` | >=50 | | Trending scores populated | `redis-cli ZCARD trending:savannah:all` | >=30 | | Stripe webhook receiving | Check Stripe dashboard -> Webhooks -> Recent deliveries | All 200 OK | | PWA Lighthouse score | `lighthouse https://app.hotnow.io --preset=perf` | Performance >80, PWA >90 | | Reddit cron configured | `cat /etc/cron.d/hotnow-reddit` | Correct schedule, valid script path | | Backup cron configured | `cat /etc/cron.d/hotnow-backup` | Daily at 3am | | Sitemap accessible | `curl https://hotnow.io/sitemap-savannah.xml` | HTTP 200, valid XML | --- ## 10. Operational Runbook ### 10.1 Monitoring #### Health Checks | Endpoint | Check | Alert Threshold | |----------|-------|----------------| | `https://api.hotnow.io/health` | API responds 200, DB + Redis connected | 3 consecutive failures | | `https://mcp.hotnow.io/health` | MCP responds 200 | 3 consecutive failures | | `https://hotnow.io` | Landing page loads | 2 consecutive failures | | `https://app.hotnow.io` | PWA loads (200) | 2 consecutive failures | | Super Search v2 :8899 | health_check() tool returns all healthy | Any provider degraded >5 min | | PostgreSQL :5432 | `pg_isready -U hotnow_app` | Connection refused >1 min | | Redis :6379 | `redis-cli PING` | PONG not returned >1 min | Uptime Kuma: Add monitors at `uptimekuma.itpropartner.com` for all endpoints above. #### Key Metrics to Monitor | Metric | Source | Warning | Critical | |--------|--------|---------|----------| | API response time (p95) | Caddy access log / app metrics | >500ms | >2s | | Trending score recalc cycle time | ARQ worker log | >3 min | >5 min (missed cycle) | | Redis memory usage | `redis-cli INFO memory` | >200MB | >230MB (approaching 256MB limit) | | PostgreSQL connection count | `pg_stat_activity` | >80 | >100 (default max) | | Stripe webhook failure rate | Stripe dashboard | >5% | >10% | | Event ingestion failures | Seed ingestion log | >20% of fetches fail | >50% | | Reddit post success rate | Reddit worker log | Any post fails | 3 consecutive failures | | Yelp/Google API quota usage | API dashboards | >70% daily quota | >90% daily quota | | Disk usage | `df -h /` | >70% | >85% | ### 10.2 Logging All services log to journald. Key log files: ```bash # API logs journalctl -u hotnow-api -f # Worker logs (trending, ingestion, reddit) journalctl -u hotnow-worker -f # MCP server logs journalctl -u hotnow-mcp -f # Caddy access logs journalctl -u caddy -f # Reddit flywheel log (cron output) tail -f /var/log/hotnow-reddit.log # Seed ingestion log (cron output) tail -f /var/log/hotnow-seed.log # PostgreSQL logs journalctl -u postgresql -f ``` ### 10.3 Common Operations #### Force Trending Recalculation ```bash curl -X POST https://api.hotnow.io/admin/recalculate-trending \ -H "Authorization: Bearer " ``` #### Manually Trigger Reddit Post ```bash /root/hotnow-api/venv/bin/python3 /root/hotnow-api/reddit_worker.py --force ``` #### Seed a New City ```bash /root/hotnow-api/venv/bin/python3 /root/hotnow-api/city_seed.py --city="Charleston" --state="SC" ``` #### Verify Ranking Scores ```bash # Check Redis sorted set redis-cli ZREVRANGE trending:savannah:all 0 9 WITHSCORES # Check DB psql -U hotnow_app -d hotnow -c \ "SELECT title, trending_score FROM events WHERE is_active=true ORDER BY trending_score DESC LIMIT 10;" ``` #### Check Ambassador Stats ```sql SELECT u.display_name, a.total_referrals, a.total_submissions, a.total_credits_earned FROM ambassadors a JOIN users u ON a.user_id = u.id ORDER BY a.total_credits_earned DESC LIMIT 10; ``` ### 10.4 Incident Response #### Scenario 1: API is down / unresponsive ```bash # 1. Check service status systemctl status hotnow-api journalctl -u hotnow-api --since "5 min ago" # 2. Common causes: # - PostgreSQL unreachable -> check pg_isready # - Port conflict -> check `ss -tlnp | grep 8001` # - Python dependency broke -> check .env file, reinstall venv # 3. Restart if needed systemctl restart hotnow-api ``` #### Scenario 2: Trending scores not updating ```bash # 1. Check worker status systemctl status hotnow-worker journalctl -u hotnow-worker --since "30 min ago" # 2. Manual trigger curl -X POST https://api.hotnow.io/admin/recalculate-trending \ -H "Authorization: Bearer " # 3. If Redis is full (allkeys-lru evicting trending keys): redis-cli INFO memory redis-cli CONFIG SET maxmemory 512mb # Double if needed ``` #### Scenario 3: Seed scraper failing ```bash # Check API key validity curl -H "Authorization: Bearer $YELP_FUSION_API_KEY" \ "https://api.yelp.com/v3/businesses/search?location=Savannah,GA&limit=1" # Check quota usage on Yelp/Google developer dashboards # If rate-limited: wait until reset, then retry with lower batch size ``` #### Scenario 4: Reddit posts not appearing ```bash # Check if shadowbanned by posting manually via Reddit web UI # Check PRAW credentials /root/hotnow-api/venv/bin/python3 -c " import praw r = praw.Reddit(client_id='...', client_secret='...', user_agent='HotNow/1.0') print(r.user.me()) " ``` ### 10.5 Backup / Restore (Detailed) #### Automated Backups File: `/etc/cron.d/hotnow-backup` ``` # Daily database backup at 3am ET 0 3 * * * root /root/hotnow-api/scripts/backup.sh >> /var/log/hotnow-backup.log 2>&1 # Weekly Redis RDB backup (Sunday 4am) 0 4 * * 0 root cp /var/lib/redis/dump.rdb /var/backups/hotnow/redis_weekly_$(date +\%Y-\%m-\%d).rdb ``` #### Restore from Backup ```bash #!/bin/bash # /root/hotnow-api/scripts/restore.sh # Example: restore.sh 2026-08-11_0300 BACKUP_DATE=$1 BACKUP_DIR=/var/backups/hotnow # Stop all HotNow services systemctl stop hotnow-api hotnow-worker hotnow-mcp # Restore PostgreSQL dropdb -U hotnow_app hotnow --if-exists createdb -U hotnow_app hotnow pg_restore -U hotnow_app -d hotnow -j 4 $BACKUP_DIR/hotnow_$BACKUP_DATE.dump # Restore Redis systemctl stop redis-server cp $BACKUP_DIR/redis_$BACKUP_DATE.rdb /var/lib/redis/dump.rdb chown redis:redis /var/lib/redis/dump.rdb systemctl start redis-server # Verify pg_isready -U hotnow_app redis-cli PING # Start services systemctl start hotnow-api hotnow-worker hotnow-mcp # Health check sleep 5 curl -f https://api.hotnow.io/health || echo "WARNING: Health check failed" ``` ### 10.6 Scaling Considerations | Threshold | Action | |-----------|--------| | 1,000 MAU | Monitor Redis memory (currently 256MB). Trending sorted set for 500+ events with scores: ~2-5MB | | 5,000 MAU | Increase Redis maxmemory to 512MB. Add Mapbox for better map tiles | | 10,000 MAU | Consider read replica for PostgreSQL (app3 has MySQL -- would need PostgreSQL on app3). Add CDN caching for static PWA assets | | 50,000 MAU | Dedicated VPS for PostgreSQL. HotNow MCP server on separate port. Redis cluster for trending data | | 100,000+ MAU | Multi-region deployment. Consider managed PostgreSQL hosting. Separate ingestion pipeline from serving API | ### 10.7 Security Checklist | Check | Status | Notes | |-------|--------|-------| | JWT tokens signed with HS256, 15-min expiry | To implement | | | Refresh token rotation on use | To implement | | | CORS restricted to hotnow.io subdomains only | Configured in Caddyfile | | | Stripe webhook signature verification | To implement | | | SQL injection prevention via parameterized queries (asyncpg) | To implement | | | Rate limiting per tier (Redis token bucket) | To implement | | | User input sanitization (event submissions, reviews) | To implement | | | HTTPS only (Caddy auto-LetsEncrypt) | Configured | | | Environment secrets not in code (`.env` file, 600 perms) | To enforce | | | Admin endpoints Tailscale-only or strong auth | To configure | | --- ## Appendix A: File Structure Reference ``` /root/hotnow-api/ # FastAPI backend main.py # FastAPI app entrypoint config.py # Settings from .env models.py # Pydantic models (exists, 125 lines) auth.py # JWT auth, login, register database.py # asyncpg connection pool ranking.py # HOT_SCORE calculator trend_worker.py # ARQ worker with cron ai_curator.py # LLM-powered venue/event enrichment reddit_worker.py # PRAW Reddit flywheel city_seed.py # Yelp/Google/Eventbrite ingestion sitemap_generator.py # Sitemap XML generator routers/ __init__.py events.py # Events endpoints venues.py # Venues endpoints discover.py # Discovery/feed endpoints auth.py # Auth endpoints billing.py # Stripe billing endpoints ambassador.py # SCAD ambassador endpoints admin.py # Admin endpoints savannah.py # Savannah-specific (neighborhoods, corridors) ai_readable.py # AI-optimized endpoints health.py # Health check endpoint signals/ __init__.py freshness.py # Freshness signal calculation velocity.py # Velocity signal from Redis pulses social.py # Social proof from Super Search contextual.py # Weather/time/proximity signals boost.py # Manual boost management aggregators/ __init__.py eventbrite.py # Eventbrite API connector ticketmaster.py # Ticketmaster API connector meetup.py # Meetup API connector migrations/ # SQL migration files 001_initial_schema.sql 002_neighborhoods.sql 003_ambassadors.sql tests/ conftest.py fixtures/ savannah_seed.sql test_ranking.py test_api_events.py test_api_auth.py test_signals.py test_mcp.py scripts/ backup.sh restore.sh .env # Environment variables (chmod 600) requirements.txt pyproject.toml /opt/hotnow-mcp/ # MCP server server.py # FastMCP 4.x server venv/ # Virtual environment requirements.txt /var/www/hotnow/ # Marketing landing page index.html # Exists (1342 lines) savannah/ index.html # Savannah-specific landing page /var/www/hotnow-app/ # PWA (SvelteKit static export) index.html _app/ # Bundled JS/CSS manifest.json sw.js # Service worker icons/ /var/www/hotnow-admin/ # Admin dashboard index.html _app/ /etc/caddy/Caddyfile # Reverse proxy config (hotnow routes exist) /etc/systemd/system/ hotnow-api.service # FastAPI service (exists) hotnow-worker.service # ARQ worker (to create) hotnow-mcp.service # MCP server (to create) /etc/cron.d/ hotnow-reddit # Reddit flywheel cron (to create) hotnow-backup # Daily backup cron (to create) ``` ## Appendix B: Key Decisions Log | Decision | Rationale | Date | |----------|-----------|------| | Launch Savannah first (not Austin) | Smaller market = faster network effects; SCAD = 15K Gen Z users; Germaine's home turf; Locale-NYC city-first strategy validation | Aug 10, 2026 | | Leaflet.js + OSM (not Mapbox) for maps | Free, no API key dependency, works offline. Mapbox becomes viable at >$500 MRR | Phase 1 doc | | FastMCP 4.x pattern (matching Super Search v2) | Proven pattern, same infrastructure, same deployment model, shared Python 3.13 venv | Phase 1 doc | | SvelteKit over React | Faster, smaller bundles, better PWA support, matches Phase 1 architecture decision | Phase 1 doc | | ARQ over Celery for task queue | Lighter weight, native Redis support, same Python process model | Phase 1 doc | | JWT (stateless) over server-side sessions | PWA-friendly, no cookie dependency, works across subdomains | Phase 1 doc | | Stripe Customer Portal for billing self-serve | Zero custom billing UI build; Stripe handles upgrades, downgrades, invoices | Phase 1 doc | | One city at a time (depth-first) | Build strong network effects in Savannah before expanding; prevents thin data problem | Phase 1 doc | | Reddit flywheel over paid ads | Locale-NYC pattern validated; zero ad spend for first city; community-driven growth | Aug 10, 2026 | ## Appendix C: Glossary | Term | Definition | |------|-----------| | **PWA** | Progressive Web App -- installable from browser, works offline, no app store | | **MCP** | Model Context Protocol -- AI agent tool integration standard | | **FastMCP** | Python MCP server framework used by Super Search v2 and HotNow MCP | | **ARQ** | Async Python task queue with Redis backend | | **PostGIS** | PostgreSQL geospatial extension for proximity/containment queries | | **SCAD** | Savannah College of Art and Design -- 15K+ students in Savannah | | **PRAW** | Python Reddit API Wrapper -- library for automated Reddit posting | | **HOT_SCORE** | HotNow's real-time ranking metric (0.0-1.0 scale) | | **PeerPush Pattern** | Structured AI-readable data layer for AI crawler/LLM consumption | | **Locale-NYC Pattern** | City-branded discovery with Reddit flywheel community growth | | **UTM** | Urchin Tracking Module -- URL parameters for campaign attribution | | **SOM** | Serviceable Obtainable Market -- addressable within 3-year window | --- Document prepared by: Hermes Agent (subagent) for Germaine Brown Date: August 11, 2026 Repository: ITPP Infrastructure (https://git.itpropartner.com/ippadmin/itpp-infrastructure) Classification: Internal Technical Documentation