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

2401 lines
92 KiB
Markdown

# 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
<!-- 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**
```html
<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
<?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)
```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 <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%)
```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:<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
```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 <admin_token>"
```
#### 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 <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
```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 <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