City transport intelligence for business travelers. You land in an unfamiliar city, Groundwork tells you what transport exists, how to pay, when it runs, and how to get from the airport.
URL: groundwork.potniq.com
# Install uv (one-time)
curl -LsSf https://astral.sh/uv/install.sh | sh
source "$HOME/.local/bin/env"
# Install/pin Python and deps for this repo
uv python install 3.13
uv venv --python 3.13
uv pip install -r requirements.txt
# Run locally (needs Postgres running)
export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/groundwork"
export PERPLEXITY_API_KEY="your-key"
export ADMIN_API_KEY="your-secret-admin-key"
export VERIFY_GENERATED_URLS="true"
export URL_VERIFICATION_TIMEOUT_SECONDS="8"
export POSTHOG_PUBLIC_KEY="phc_your_project_key" # optional
export POSTHOG_HOST="https://us.i.posthog.com" # optional
export POSTHOG_DEBUG="false" # optional
export POSTHOG_CAPTURE_CONSOLE_ERRORS="true" # optional
export POSTHOG_RECORD_CONSOLE_LOGS="true" # optional
uv run uvicorn app.main:app --reload --port 8000
# Run tests
uv run pytest tests/ --junitxml=test-results/results.xml
# Run with Docker
docker build -t groundwork .
docker run -p 8000:8000 --env-file .env groundwork- FastAPI + Jinja2 templates (no frontend build step)
- PostgreSQL (city intel stored in JSONB)
- Perplexity Sonar Pro for city transport research
- Docker for runtime/CI
- Supabase CLI migrations
- Python 3.13
slug,city_name,country,country_code- optional
latitude,longitude,metro_area_name statusingenerating | ready | failedintelJSONB validated asCityIntel
raw_input, optionalemailstatusinpending | fulfilled | ignored- visitor requests are stored for manual admin review
GET /homepage with search/filter + city request formGET /citieslist ready citiesGET /cities/{slug}city JSONGET /{slug}city HTML guidePOST /citiesadmin-only generation endpoint (X-API-Key)GET /requestspublic HTML page listing submitted city requestsPOST /requestspublic city request intakeGET /healthhealthcheck
{
"city_name": "Barcelona",
"country": "Spain",
"country_code": "ES",
"latitude": 41.3874,
"longitude": 2.1686,
"slug": "barcelona-es"
}- If
slugis omitted, it auto-generates asslugify("{city_name}-{country_code}"). - Perplexity is called synchronously; failed generation sets
status='failed'. - Generated links are validated server-side; if invalid links are found, generation is retried once with corrective feedback.
{
"raw_input": "Joburg",
"email": "optional@example.com"
}- Inserts into
city_requestswithstatus='pending'. - No automatic city generation is triggered.
Create a new migration:
./scripts/new_migration.sh <name>
# or: supabase migration new <name>Apply migrations to a database URL:
./scripts/run_migrations.sh
# or: supabase db push --db-url "$DATABASE_URL"Use the helper script to trigger POST /cities against local API by default:
./scripts/research_city.sh \
--city-name "Barcelona"- Defaults to
http://127.0.0.1:8000 - Reads
ADMIN_API_KEYfrom.envautomatically - Auto-resolves
countryandcountry_codefrom city name if omitted - Override API URL/key with
--api-urland--api-key
Generate a starter set of 30 cities sequentially:
./scripts/research_cities_batch.sh- Uses a predefined list of 30 major cities
- Calls
./scripts/research_city.shone-by-one - Optionally throttle requests:
--delay-seconds 2
- Unit tests (no DB):
pytest -m unit tests/unit
- Integration tests (Postgres-backed):
pytest -m integration tests/integration
- Perplexity responses are mocked with
pytest-httpx - Coverage includes city creation/auth, custom slug, city requests flow, and unit helpers
- PostHog web analytics is optional and anonymous by default.
- Set
POSTHOG_PUBLIC_KEYto enable the browser snippet. POSTHOG_HOSTdefaults tohttps://us.i.posthog.com; override it if your PostHog project is hosted elsewhere.POSTHOG_DEBUGenables verbose PostHog SDK logging; it is also enabled automatically onlocalhostand127.0.0.1.POSTHOG_CAPTURE_CONSOLE_ERRORSenables PostHog exception capture for browser console errors.POSTHOG_RECORD_CONSOLE_LOGSrecords console logs alongside session replay when PostHog supports it.- Groundwork initializes PostHog with
person_profiles="identified_only"so anonymous traffic is captured until a user explicitly shares their email. - Groundwork also sends explicit product events for meaningful user actions instead of relying only on generic click autocapture.
- When a city request is submitted with an email address, Groundwork calls
identify(email, { email, latest_requested_city })and includesrequester_emailplusrequested_city_inputoncity_request_submitted. This makes PostHog workflows available for thank-you and follow-up emails tied to the submitted request. - City pages also register PostHog super properties so all subsequent events on that page can be attributed to the current city guide.
- Groundwork mirrors custom analytics events to the browser console and sends structured
client_log/client_exceptiondata into PostHog for handled client-side issues. - Browser-visible 404 and 500 pages are rendered as HTML and tracked with PostHog so broken page hits are measurable.
- Built-in PostHog events:
$pageview$pageleave
- Custom Groundwork events:
city_search_performed: homepage search used, with query length and result count.city_guide_opened: a guide card was opened from the homepage, including city and entrypoint (card_titleorcard_cta).city_guide_viewed: a city guide page loaded, including city and guide-shape metadata.guide_section_viewed: a guide section entered the viewport for the first time.guide_resource_clicked: a user clicked an outbound authority, app-store, payment, airport, or delay-info link.feedback_survey_requested: the city-guide feedback trigger was clicked. Use this event in PostHog survey display conditions instead of a CSS selector trigger.posthog_loaded: the PostHog SDK loaded with the current debug/error-capture settings.client_log: an application log line was mirrored into PostHog from the browser.client_exception: a handled client-side exception was captured whencaptureExceptionwas not available.page_not_found_viewed: an HTML 404 page was shown to the visitor.application_error_viewed: an HTML 500 page was shown to the visitor.city_request_started: a user engaged with the request-city form.city_request_submitted: a city request was submitted successfully, including the requested city text and requester email when provided.city_request_submission_failed: request submission failed client-side or server-side.navigation_clicked: header navigation back to home.external_link_clicked: footer outbound links to Potniq or GitHub.
- For the city feedback survey, prefer PostHog's "user sends event" display condition with
feedback_survey_requested. - Do not target
.feedback-buttonas a CSS selector in survey conditions, because selector-based triggers can fire whenever the element is present on the page.
For a detailed pipeline writeup, see docs/cicd/pipeline.md.
- Non-main branches: run
install-deps->test-unit+test-integration+scan-python-deps mainbranch: runinstall-deps->test-unit+test-integration+scan-python-deps->build-docker->verify-docker+scan-docker-image->run-production-migrations->push-docker->deploy-digitalocean
groundwork_docker context:
DOCKERHUB_USERNAMEDOCKERHUB_PASSWORD(Docker Hub access token recommended)DOCKERHUB_REPO(example:your-org/groundwork)
groundwork_supabase context:
SUPABASE_DB_URL(direct Postgres connection string for production, percent-encoded if needed)
groundwork_digitalocean context:
DIGITALOCEAN_ACCESS_TOKENDIGITALOCEAN_APP_ID
snyk context:
SNYK_TOKEN
- CircleCI pushes two tags to Docker Hub on
main:${CIRCLE_SHA1}latest
- Python dependencies are installed in a dedicated
install-depsjob, cached, and persisted to workspace as/home/circleci/.localfor downstream test/scan jobs. - CircleCI runs Snyk dependency scanning against
requirements.txtbefore Docker build. - Docker image build and push are split into separate jobs (image is saved/loaded via workspace) to keep deploy sequencing explicit and more reliable.
- CircleCI verifies the built image by starting Postgres + app containers, polling
GET /health, and running API E2E smoke checks (/cities,/cities/{slug},/requests) withPERPLEXITY_MOCK_RESPONSE_FILEset to a fixture. - CircleCI runs Snyk container scanning on the built image (
${DOCKERHUB_REPO}:latest) before migrations/push. - CircleCI runs production SQL migrations before deploy using:
npx supabase@latest db push --db-url "$SUPABASE_DB_URL" --include-all
- CircleCI then triggers App Platform deploy using:
doctl apps create-deployment "$DIGITALOCEAN_APP_ID" --wait
- Runtime app secrets (
DATABASE_URL,PERPLEXITY_API_KEY,ADMIN_API_KEY) should be set in DigitalOcean App Platform settings.