Files
itpp-infrastructure/projects/hotnow-savannah-technical.md

92 KiB

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
  2. New Technical Features
  3. Data Model
  4. API Surface
  5. Ranking Algorithm
  6. Implementation Roadmap
  7. Infrastructure & Deployment
  8. Integration Points
  9. Testing & QA Strategy
  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

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:

<!-- Venue page: /venue/:id -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "EntertainmentBusiness",
  "name": "The Jinx",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "127 W Congress St",
    "addressLocality": "Savannah",
    "addressRegion": "GA",
    "postalCode": "31401"
  },
  "geo": {
    "@type": "GeoCoordinates",
    "latitude": 32.0809,
    "longitude": -81.0912
  },
  "openingHoursSpecification": [...],
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "{{ trending_score | format_rating }}",
    "bestRating": "10",
    "ratingCount": "{{ social_mention_count }}"
  }
}
</script>

<!-- Event page: /event/:id -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Event",
  "name": "Live Jazz at The Jinx",
  "startDate": "2026-08-15T20:00:00-04:00",
  "location": { "@type": "Place", ... },
  "performer": { "@type": "MusicGroup", "name": "..." },
  "offers": {
    "@type": "Offer",
    "price": "0",
    "priceCurrency": "USD"
  }
}
</script>

Layer 2: Open Graph and Twitter Cards

<meta property="og:title" content="{{ event.title }} - HotNow Savannah" />
<meta property="og:description" content="{{ event.description | truncate(200) }}" />
<meta property="og:image" content="{{ event.cover_image_url }}" />
<meta property="og:url" content="https://hotnow.io/savannah/event/{{ event.id }}" />
<meta property="og:type" content="event" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@hotnow_app" />

Layer 3: Sitemap with Structured Data

https://hotnow.io/sitemap-savannah.xml:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:event="http://www.google.com/schemas/sitemap-event/1.0">
  <url>
    <loc>https://hotnow.io/savannah/event/550e8400-e29b</loc>
    <lastmod>2026-08-11</lastmod>
    <changefreq>hourly</changefreq>
    <priority>0.9</priority>
  </url>
  <!-- ... all active events + venues ... -->
</urlset>

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)

#!/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

[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:

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:

**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

#!/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

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?"

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?"

-- 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:

// 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

#!/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

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)

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)

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)

-- 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)

-- 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)

-- id, event_id (FK), user_id, action (discover_action),
-- source_context, created_at

business_features (as defined in hotnow-phase1.md)

-- id, venue_id (FK), feature_type, status, starts_at, ends_at,
-- stripe_payment_id, amount_paid_cents, metadata, created_at

ambassadors (new -- SCAD program)

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)

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)

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)

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

-- 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 <access_token>

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%)

# 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%)

# 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%)

# 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%)

# 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):

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

#!/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)

[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

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

# Database
DATABASE_URL=postgresql://hotnow_app:<PASSWORD>@localhost:5432/hotnow
REDIS_URL=redis://localhost:6379/0

# Auth
JWT_SECRET=<generate-256-bit>
JWT_REFRESH_SECRET=<generate-256-bit>

# 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=<admin-ai-key>
LITELLM_BASE_URL=https://admin-ai.itpropartner.com/v1

# External APIs
YELP_FUSION_API_KEY=<key>
GOOGLE_PLACES_API_KEY=<key>
EVENTBRITE_API_TOKEN=<key>
TICKETMASTER_API_KEY=<key>
MEETUP_API_KEY=<key>

# Reddit (PRAW)
REDDIT_CLIENT_ID=<id>
REDDIT_CLIENT_SECRET=<secret>
REDDIT_USERNAME=hotnow_app
REDDIT_PASSWORD=<password>

# Weather (OpenWeatherMap)
OPENWEATHER_API_KEY=<key>

# Email
RESEND_API_KEY=<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

#!/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

# 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:

# 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

curl -X POST https://api.hotnow.io/admin/recalculate-trending \
  -H "Authorization: Bearer <admin_token>"

Manually Trigger Reddit Post

/root/hotnow-api/venv/bin/python3 /root/hotnow-api/reddit_worker.py --force

Seed a New City

/root/hotnow-api/venv/bin/python3 /root/hotnow-api/city_seed.py --city="Charleston" --state="SC"

Verify Ranking Scores

# 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

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

# 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
# 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 <admin_token>"

# 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

# 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

# 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

#!/bin/bash
# /root/hotnow-api/scripts/restore.sh <backup_date>
# 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