2401 lines
92 KiB
Markdown
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
|