Amigos Gathering

Program Flow & Technology Reference

April 2026 

 

1. Overview


Amigos Unite is a full-stack event management platform built for social organizers. It lets any registered Amigo create and publish community events, search for venues using Google Maps and AI-assisted filtering, manage event staff through a role hierarchy, and track RSVPs. The platform is composed of two independent repositories that communicate exclusively over HTTPS.

The backend API (amigos_unite_api) is a Ruby on Rails 7.1 application running in API-only mode. It handles authentication, business logic, database persistence, background jobs, and all integrations with external services. The frontend (amigos_unite_app) is a React 18 / TypeScript / Vite single-page application. It communicates with the API via authenticated Axios requests, enforces CSRF protection, and renders a multi-step event creation wizard.


2. Technology Stacks


2.1 Backend — amigos_unite_api

The API is a Rails 7.1 application running on Ruby 3.2.2. It uses PostgreSQL 17 as its primary data store with the PostGIS adapter for optional geospatial queries. Background jobs run through Sidekiq backed by Redis.

Layer
Technology
Language
Ruby 3.2.2
Framework
Rails 7.1 (API-only mode, no asset pipeline)
Web Server
Puma 6 — multi-threaded, binds on port 3001
Database
PostgreSQL 17 with activerecord-postgis-adapter
Job Queue
Sidekiq 8 backed by Redis 7
Authentication
Devise 4.9 + devise-jwt — stateless JWT access tokens, refresh tokens stored in HTTP-only cookies
OAuth
OmniAuth with strategies for Google, Facebook, GitHub, LinkedIn
Serialization
ActiveModelSerializers 0.10 — JSON:API-style attribute adapters
Encryption
Lockbox + Blind Index — field-level encryption for PII (phone numbers, sensitive profile data)
File Storage
Rails Active Storage — local disk in dev, VPS-mounted volume in production
Image Processing
uby-vips + image_processing — resizing and format conversion
Virus Scanning
Clamby (ClamAV) — scans uploads before Active Storage attachment
Rate Limiting
Rack::Attack — per-IP throttles on auth and API endpoints
Google Places
Custom GooglePlaces::Client service using HTTParty — Nearby Search, Place Details, Geocoding, Photos
AI Filtering
Anthropic Claude Haiku 4.5 — filters venue candidates against user criteria via HTTParty POST
AI / Agentic
Anthropic SDK 1.36.0 — Claude Haiku 4.5 with tool use
Geocoding
Geocoder gem + GeocodableWithFallback concern — fills missing address fields on AmigoLocation and EventLocation save
Phone Validation
phonelib gem — E.164 normalization and carrier lookup
Geo / Country
countries gem — ISO 3166 lookups for country / state dropdowns
CORS
rack-cors — allows https://amigosunite.org in production, localhost:5173 in development
Testing
Rails 7.1 (API-only mode, no asset pipeline)RSpec-Rails 7.1 + FactoryBot + Faker
Build / CI
GitHub Actions — runs RSpec on PRs targeting main2 Frontend — amigos_unite_app

When an event organizer supplies free-text venue criteria (e.g. "cool vibes", "artsy with indoor seating", "good for a corporate mixer"), the API delegates the entire search to Claude Haiku 4.5 via the official Anthropic Ruby SDK (anthropic 1.36.0). Rather than pre-processing the criteria with hand-written keyword rules, Claude drives the search autonomously through an agentic tool-use loop.

Aspect
Detail
Model
claude-haiku-4-5-20251001
SDK
anthropic gem 1.36.0 (official Anthropic Ruby SDK)
Pattern
Agentic tool-use loop — Claude decides what to search, how many times, and when to stop
Tools exposed
search_venues (Google Places Text Search + Nearby Search fallback) and get_venue_details (Places Details by place_id)
Max turns
8 — safety cap on agentic iterations before the loop is forced to terminate
Output
Curated JSON array — each venue includes a criteria_matched count and a reason string explaining why it fits the organizer's criteria; results are ranked by criteria match then by Google rating
Safety net
Two-stage type filter: a pre-filter strips ineligible venues (hospitals, government offices, etc.) before Claude sees them; a post-filter re-checks using cached real Google Places types so Claude cannot bypass exclusions by altering the types field in its JSON output
Routing
Three-path coordinator: named-venue search (no AI) → agentic search (Haiku, when free-text criteria are present) → category search (no AI, when no criteria are given)

2.3 Frontend — amigos_unite_app

The frontend is a TypeScript React 18 single-page application bundled with Vite 7. It uses React Router v6 for client-side routing and Axios for all API communication.

Layer
Technology
Language
TypeScript 5.2 + React 18
Build Tool
Vite 7 — fast HMR in dev, ES module output for prod
Routing
React Router v6 — ProtectedRoute wrapper enforces auth
HTTP Client
Axios — privateApi instance with JWT bearer token injection, CSRF header management, and automatic token-refresh on 401
Styling
SCSS / Sass — compiled to CSS; BEM methodology; multi-theme palette system (public + admin)
Maps
LocationPickerMap component — renders Google Maps via Maps JavaScript API; Places Nearby Search results shown as pins
Linting
ESLint with TypeScript, react-hooks, and jsx-a11y plugins
Type Checking
GitHub Actions — runs lint + typecheck on PRs targeting main
Testing
GitHub Actions — runs lint + typecheck on PRs targeting main


2.4 Infrastructure & Deployment

Both repos are deployed to a single VPS (Ubuntu, managed by Hetzner) using Docker Compose. Nginx runs on the host as a reverse proxy and terminates TLS via Let's Encrypt.

Layer
Technology
Host OS
Ubuntu 22.04 LTS on Hetzner VPS
Container Runtime
Docker (CLI v2 plugin) — no docker-compose binary; uses `docker compose
API Containers
api (Puma) + sidekiq — both use the same ghcr.io image built on main
Frontend Container
Nginx serving the Vite static build on port 8080
Database
postgres:17 container with volume mount at /opt/amigos_unite_api/data/postgres
Cache / Queue
redis:7-alpine with AOF persistence at /opt/amigos_unite_api/data/redis
Reverse Proxy
Nginx on host — routes /api/* to localhost:3001, all other traffic to localhost:8080
TLS
Let's Encrypt / Certbot — auto-renewing certificates for amigosunite.org
Registry
GitHub Container Registry (ghcr.io) — Docker images pushed on merge to main
CI/CD
GitHub Actions — backend-ci (RSpec) + frontend-ci (tsc + lint) on PRs; deploy workflow SSHes to VPS on merge to main and runs 'docker compose pull && docker compose up -d'
Secrets
GitHub Actions Secrets — RAILS_MASTER_KEY, VPS_SSH_KEY, GHCR credentials
Firewall
ufw — ports 22, 80, 443 open; 3001 and 8080 bound to 127.0.0.1 only

3 Authentication Flow


Authentication is stateless. The API issues short-lived JWT access tokens on login and stores long-lived refresh tokens in HTTP-only cookies. The frontend holds the access token in memory (Authorization header) and retries automatically when it expires.

Step
Actor / Layer
Action
1
Browser → API
POST /api/v1/login with email + password (or OAuth callback)
2
API (Devise-JWT)
Validates credentials, issues access JWT (sub = amigo.id, exp = 1 hour), sets refresh token in HTTP-only cookie
3
Browser
Stores access token in memory via privateApi.defaults.headers; also persisted to localStorage as fallback
4
Browser → API
Every subsequent request includes Authorization: Bearer <token> header
5
API (authenticate_amigo!)
Decodes JWT, loads Amigo from sub claim, sets current_amigo
6
Access token expires
privateApi interceptor catches 401, POSTs /api/v1/refresh_token with cookie
7
API (refresh_token)
Validates refresh token, issues new access JWT, returns in response body
8
Browser
Updates Authorization header and retries the original failed request
9
Refresh also fails
triggerAuthRequired() opens login modal; all credentials are cleared

4. Event Management Flow


4.1 Creating an Event

EventForm is a seven-step wizard rendered by CreateEventPage. Steps 1-2 capture core event details, steps 3-6 are the location search and venue selection workflow, and step 7 is only shown in edit mode for staff management. On the final step the form calls onSubmit which invokes EventService.createEvent.

Step
Actor / Layer
Action
1
Amigo (Step 1-2)
Fills event name, date, time, type, description, status, and optional speakers / performers
2
Amigo (Steps 3-5)
Enters city + criteria; AI location search fetches Google Maps candidates and filters them through Claude Haiku; Amigo selects a map pin and picks a venue photo
3
Amigo (Step 6)
Reviews and edits pre-filled address details populated from Google Places Details API
4
Browser → API
POST /api/v1/events with event + nested location attributes
5
API (EventPolicy)
create? checks that current_amigo is present
6
API (Events::CreateEvent)
Creates Event record with lead_coordinator = current_amigo; creates EventAmigoConnector (role: lead_coordinator); optionally calls Events::UpsertPrimaryLocation
7
API → Browser
Returns EventSerializer JSON (includes primary_event_location)
8
Browser
Navigates to My Events page

4.2 Editing an Event

EditEventPage loads the event and its connectors in parallel. EventForm is pre-populated from both. On edit the wizard includes an optional Step 7 (Manage Staff). If the event already has a location, steps 3-5 show a 'Keep current location' shortcut that jumps directly to step 6, making re-entry of location criteria optional.

Step
Actor / Layer
Action
1
Browser → API
GET /api/v1/events/:id — loads event + primary_event_location
2
Browser → API
GET /api/v1/events/:id/event_amigo_connectors — loads staff roster
3
Amigo
Edits any fields across steps 1-6; may skip location steps if venue unchanged
4
Browser → API
PATCH /api/v1/events/:id with updated attributes
5
API (EventPolicy)
update? allows lead coordinator or assistant coordinator
6
API (EventsController)
Runs inside a transaction; handles optional lead transfer and location upsert
7
API (GeocodableWithFallback)
Before saving EventLocation, Geocoder fills missing postal code / state / country if sufficient address is present

5. Location Intelligence Flow


Step 3 of the event form collects a city, optional venue name, up to three venue category buckets, and up to five free-text criteria. This drives a two-step server-side search combining Google Places and Claude AI.

 

Step
Actor
Action
1
Browser (React SPA)
User enters city, up to 3 venue types, and optional free-form event criteria (e.g. "cool vibes", "quiet with WiFi")
2
Browser → API
POST /api/v1/location_suggestions with city, venue_types[], criteria[], optional venue_name, lat/lng, event_start_time
3
API (AiLocationSuggestions)
Geocodes city to lat/lng if not provided (Geocoder gem → Google Geocoding API). Routes request by input type: venue_name present → keyword search path; criteria present → Agentic Haiku path; neither → category Nearby Search path
4
API (AgenticLocationSearch → Claude Haiku 4.5)
Free-form criteria and coordinates are passed to AgenticLocationSearch, which opens an agentic tool-use loop with Claude Haiku 4.5 (claude-haiku-4-5) via the Anthropic Ruby SDK 1.36. Claude receives a system prompt containing the organizer's numbered criteria, the search area, and two tool definitions: search_venues and get_venue_details
5
Claude Haiku ↔ Google Places API
Claude autonomously formulates search queries from the criteria intent and calls the search_venues tool. Each call triggers a Google Places Text Search (semantic intent matching); if fewer than 3 results are returned, a Nearby Search runs as a fallback. Results from both are merged and deduplicated by place_id
6
API (pre-filter) → Claude Haiku (evaluate)
Before results are returned to Claude, a pre-filter removes ineligible venue types (hospitals, banks, government offices, etc.) and caches real Google Places types by place_id. Claude evaluates the eligible results; if address detail is needed for a specific venue it calls get_venue_details. Claude may repeat the loop — calling search_venues again with different query angles — up to a hard cap of 8 turns
7
Claude Haiku → API
When satisfied, Claude ends the loop (stop_reason: end_turn) and returns a raw JSON array. Each element contains place_id, name, formatted_address, rating, types, a criteria_matched count (how many of the organizer's criteria are likely met), and a reason string explaining the match per criterion
8
API (post-filter + sort)
A post-filter re-checks every venue's types against the cached real Google Places data — preventing Claude from bypassing exclusions by omitting or altering the types field in its JSON output. Results are sorted by criteria_matched descending, then by rating descending, and capped at 20 venues
9
API → Browser
Returns up to 20 venue objects with place_id, name, formatted_address, lat/lng, rating, types, criteria_matched, and description (the reason string). Falls back to a category-based Nearby Search if no criteria were provided or if the agentic call fails
10
Browser (React + Google Maps)
Renders each venue as a push pin on a Google Maps JavaScript API instance. The Amigo selects a pin to view the venue's name, rating, and match reason
11
Browser → API
GET /api/v1/places/:place_id/photos — requests photo references for the selected venue
12
API → Google Places Details
Calls Google Places Details API; returns up to 10 photo references with 640px preview URLs
13
Browser (React)
Renders a full photo grid — user browses all available photos. No AI filtering applied; all Google Places photos shown as-is
14
Browser (Amigo)
Selects preferred photo from the grid
15
Browser → API
POST /store_location_image { photo_reference } — submits the chosen photo reference
16
API → Google CDN → Active Storage
Rails downloads the full-resolution image (1200px) from the Google CDN and attaches it to the EventLocation record via Active Storage
17
API (Background Job)
ProcessLocationImageJob resizes the image to 640×480 and saves the final image URL to the location record

6. Participant & Staff Management Flow 


6.1 Role Hierarchy

Every relationship between an Amigo and an Event is represented by an EventAmigoConnector record. The connector carries a role enum and a status enum.

 

Layer
Technology
lead_coordinator (2)
Created automatically when an event is created. Full event management rights — can edit event, manage all roles, transfer lead.
assistant_coordinator (1)
Promoted from participant by the lead coordinator. Can edit event details and manage participant connectors but cannot change roles.
participant (0)
Any Amigo who subscribes to an event. Amigos with willing_to_help = true on their AmigoDetail profile are eligible for promotion.

6.2 Subscribing to an Event

Step
Actor
Action
1
Amigo → API
POST /api/v1/events/:id/event_amigo_connectors with amigo_id
2
API (EventPolicy)
manage_connectors? — allows lead, assistant, or self-join
3
API
Creates EventAmigoConnector with role: participant, status: pending

6.3 Promoting to Assistant Coordinator

In Edit Event Step 8, the lead coordinator sees three sections: Lead Coordinator (read-only), existing Assistant Coordinators (read-only), and Eligible Participants (checkboxes, limited to willing_to_help = true). Checking a box marks the participant 'will be promoted on save'. On form submit, onSubmitManagement fires after the event core save.

Step
Actor
Action
1
EventForm (Step 8)
Lead coordinator checks participant checkboxes; assistantCoordinatorIds state updates
2
Form submit
onSubmit saves event core first; if successful, onSubmitManagement is called
3
Browser → API
PATCH /api/v1/events/:id/event_amigo_connectors/:connector_id with { event_amigo_connector: { role: 'assistant_coordinator' } }
4
API (EventPolicy)
manage_roles? — requires lead_coordinator role (or lead_coordinator_id match on event row as fallback)
5
API (Events::ChangeRole)
Finds connector by amigo_id, updates role to assistant_coordinator
6
Browser
Navigates to Manage Events on completion; next form load shows promoted Amigo in Assistant Coordinators section

7. Core Data Model


The following are the principal ActiveRecord models and their relationships.

 

Layer
Technology
Amigo
Core user record. Has one AmigoDetail (profile) and many AmigLocation records. Encrypted phone fields via Lockbox. Blind-indexed for search.
AmigoDetail
Extended profile: willing_to_help boolean (gates assistant coordinator eligibility), bio, avatar, social handles.
AmigoLocation
Venue record. Includes GeocodableWithFallback concern — auto-geocodes missing fields on save. Stores Google place_id, image_url (Active Storage).
AmigoLocationConnector
Join table between Amigo and AmigoLocation. Supports a primary_location flag.
Event
Core event record. belongs_to :lead_coordinator (Amigo). Has many EventAmigoConnectors and has one primary EventLocation through EventLocationConnector.
EventAmigoConnector
Join table between Event and Amigo. Carries role enum (participant, assistant_coordinator, lead_coordinator) and status enum (pending, confirmed, active, declined). Enforces single lead per event.
EventLocation
Venue record. Includes GeocodableWithFallback concern — auto-geocodes missing fields on save. Stores Google place_id, image_url (Active Storage), photo_reference.
EventLocationConnector
Join table between Event and EventLocation. Supports a primary_location flag.

8. Security Architecture


Security is applied in multiple layers across both the API and the deployment infrastructure.

Authentication & Tokens

  • JWT access tokens expire in 1 hour; signed with HS256 using Rails master key material
  • Refresh tokens stored in HTTP-only, Secure, SameSite=None cookies — not accessible to JavaScript
  • Devise jti_matcher invalidates refresh tokens on logout
  • AuthTrail logs every login attempt for audit and anomaly detection

CSRF Protection

  • ApplicationController sets CSRF-TOKEN cookie on every GET response
  • verify_csrf_token runs before all mutating API calls — compares cookie value to X-CSRF-Token header using constant-time comparison
  • Frontend Axios interceptor reads the cookie and injects the header automatically

Input Security

  • Strong Parameters (ActionController::Parameters) whitelists every attribute accepted by each controller
  • Sanitize gem strips unsafe HTML from user-supplied text fields
  • phonelib normalizes and validates all phone numbers to E.164 format
  • Active Storage Validations enforces file type and size limits on uploads
  • Clamby (ClamAV) virus-scans every uploaded file before attachment

Data Protection

  • Lockbox encrypts sensitive fields at rest using AES-256-GCM
  • Blind Index provides searchable encrypted columns without exposing plaintext
  • PostgreSQL data volume is never exposed to the host network; port 5432 is internal only

Network & Infrastructure

  • ufw limits inbound traffic to ports 22, 80, 443 only
  • API and frontend containers bind only to 127.0.0.1 — Nginx is the sole public-facing process
  • Rack::Attack rate-limits authentication endpoints and API paths per IP
  • TLS 1.2+ enforced via Nginx + Let's Encrypt; HTTP redirects to HTTPS
  • Docker images are built in GitHub Actions and pulled from ghcr.io — no source code on VPS

9. End-to-End Request Lifecycle


The following traces a single authenticated mutating request — for example, saving an edited event — from the browser through to the database and back.

 

#
Actor
Action
1
Browser (Axios)
Injects Authorization: Bearer <JWT> and X-CSRF-Token headers; sends PATCH https://amigosunite.org/api/v1/events/12
2
Nginx (host)
Terminates TLS; proxies /api/* to localhost:3001 (Puma container)
3
Rack::Attack
Checks per-IP rate limits; passes if within thresh
4
Rack CORS
alidates Origin header matches allowlist
5
ApplicationController
verify_csrf_token — compares X-CSRF-Token header to CSRF-TOKEN cookie
6
ApplicationController
uthenticate_amigo! — decodes JWT, loads Amigo from sub claim
7
EventsController#update
set_event loads Event.find(12); authorize_event! instantiates EventPolicy; update? returns true for lead or assistan
8
ActiveRecord transaction
event.update! runs; Events::UpsertPrimaryLocation called if location block present; GeocodableWithFallback geocodes any missing address fields before_validation
9
ActiveModelSerializer
EventSerializer renders Event + primary_event_location as JSON
10
Puma → Nginx → Browser
200 OK with JSON body; Axios promise resolves; React state updates

If step 6 fails (e.g. the JWT has expired), the Axios interceptor transparently posts to /api/v1/refresh_token, swaps in the new token, and retries the original PATCH — the calling code never sees the interruption.

 

Screenshots

Event creation form
Screenshot 2026 05 03 at 6.44.21 PM

Screenshot 2026 05 03 at 6.46.05 PM

Screenshot 2026 05 03 at 6.47.09 PM

Screenshot 2026 05 03 at 6.47.24 PM

Screenshot 2026 05 03 at 6.47.35 PM

Screenshot 2026 05 03 at 6.47.58 PM

Screenshot 2026 05 03 at 6.48.24 PM

Screenshot 2026 05 03 at 7.03.11 PM

Screenshot 2026 05 03 at 7.03.27 PM

Screenshot 2026 05 03 at 7.03.37 PM

Screenshot 2026 05 03 at 7.03.49 PM

Screenshot 2026 05 03 at 7.04.00 PM

Screenshot 2026 05 03 at 7.04.30 PM

Screenshot 2026 05 03 at 7.05.00 PM

Screenshot 2026 05 03 at 7.29.33 PM

User profile page