graph TB
subgraph "Tenant A - ECS Task"
A_APP[Phoenix App] --> A_DB[(PostgreSQL)]
A_OBAN["DiscoverySyncWorker<br/>every 15 min"] --> A_APP
A_OBAN -->|"PutItem"| DYNAMO
end
subgraph "Tenant B - ECS Task"
B_APP[Phoenix App] --> B_DB[(PostgreSQL)]
B_OBAN["DiscoverySyncWorker<br/>every 15 min"] --> B_APP
B_OBAN -->|"PutItem"| DYNAMO
end
subgraph "Tenant C - ECS Task"
C_APP[Phoenix App] --> C_DB[(PostgreSQL)]
C_OBAN["DiscoverySyncWorker<br/>every 15 min"] --> C_APP
C_OBAN -->|"PutItem"| DYNAMO
end
DYNAMO[("DynamoDB<br/>pewpros-discovery<br/>(sync bus)")]
subgraph "Marketing Site - pewpros.com"
INGEST["DiscoveryIngestWorker<br/>every 5 min"] -->|"Scan all listings"| DYNAMO
INGEST -->|"Upsert rows"| PG[(PostgreSQL<br/>discovery_listings<br/>tsvector + GIN indexes)]
DISCOVER["DiscoverLive"] -->|"Full-text search<br/>+ keyword filter<br/>+ category filter"| PG
DISCOVERAPI["API: /api/v1/discover"] -->|"Ecto queries"| PG
end
BROWSER[Browser] --> DISCOVER
MOBILE[Mobile App] --> DISCOVERAPI
flowchart LR
subgraph "Each Tenant"
LOCAL_DB[(Tenant DB)] --> SYNC["DiscoverySyncWorker"]
end
SYNC -->|"ExAws PutItem<br/>every 15 min"| DYNAMO[("DynamoDB<br/>sync bus")]
subgraph "Marketing Site"
DYNAMO -->|"Scan every 5 min"| INGEST["DiscoveryIngestWorker"]
INGEST -->|"Ecto upsert"| PG[("Postgres<br/>discovery_listings")]
PG -->|"tsvector search"| LIVE["DiscoverLive"]
PG -->|"Ecto queries"| API["JSON API"]
end
flowchart TD
START(["DiscoverySyncWorker runs"]) --> CHECK_OPT{"discovery_enabled?"}
CHECK_OPT -->|No| DELETE["Delete listing from DynamoDB"]
CHECK_OPT -->|Yes| CHECK_ELIG{"Meets eligibility?"}
CHECK_ELIG -->|No| DELETE
DELETE --> DONE(["Return ok"])
CHECK_ELIG -->|Yes| COLLECT["Collect local data"]
COLLECT --> TENANT["Tenant profile<br/>name, domain, logo, bio"]
COLLECT --> STATS["Community stats<br/>members, active, posts"]
COLLECT --> EVENTS["Upcoming events<br/>top 3 by date"]
COLLECT --> COURSES["Published courses<br/>top 3 by enrollments"]
COLLECT --> KEYWORDS["Keywords + categories<br/>from admin settings"]
TENANT --> SCORE["Compute ranking score"]
STATS --> SCORE
EVENTS --> SCORE
COURSES --> SCORE
KEYWORDS --> SCORE
SCORE --> WRITE["Write to DynamoDB"]
WRITE --> META["PutItem sk=METADATA<br/>full profile + keywords"]
WRITE --> CATS["BatchWriteItem sk=CAT items<br/>one per category"]
WRITE --> CLEANUP["Delete stale CAT items"]
META --> DONE
CATS --> DONE
CLEANUP --> DONE
graph TB
subgraph "discovery_listings table"
COLS["name | tagline | bio | keywords | categories<br/>ranking_score | stats | featured_events | ..."]
TSVEC["search_vector TSVECTOR GENERATED ALWAYS AS<br/>weight A: name + keywords<br/>weight B: tagline + categories<br/>weight C: bio"]
end
subgraph "Indexes"
GIN_FTS["GIN index on search_vector<br/>(full-text search)"]
GIN_TRGM["GIN index on name with pg_trgm<br/>(fuzzy / typo tolerance)"]
GIN_KW["GIN index on keywords array<br/>(keyword filtering)"]
GIN_CAT["GIN index on categories array<br/>(category filtering)"]
BTREE["B-tree on site_type + ranking_score<br/>(browse + sort)"]
end
COLS --> TSVEC
TSVEC --> GIN_FTS
COLS --> GIN_TRGM
COLS --> GIN_KW
COLS --> GIN_CAT
COLS --> BTREE
flowchart LR
USER["User types:<br/>concealed carry"] --> FTS{"Full-text search<br/>plainto_tsquery"}
FTS --> RANK["Results ranked by<br/>ts_rank with weights<br/>A: name + keywords<br/>B: tagline<br/>C: bio"]
USER2["User types:<br/>detriot"] --> TRGM{"Trigram similarity<br/>pg_trgm"}
TRGM --> FUZZY["Matches Detroit<br/>despite typo"]
USER3["User clicks:<br/>beginner-friendly pill"] --> ARRAY{"Array overlap<br/>keywords && ARRAY"}
ARRAY --> FILTERED["All communities<br/>tagged beginner-friendly"]
classDiagram
class DynamoDB_SyncBus {
+String pk : LISTING_tenant_slug
+String sk : METADATA or CAT_category
+String site_type : pewpros or coachpros
+String tenant_slug
+String name
+String domain
+String tagline
+String bio
+List keywords : admin configured tags
+List categories
+Object stats
+List featured_events
+List featured_courses
+Number ranking_score : 0 to 100
+Number ttl : 7 day auto expire
}
class Postgres_SearchTable {
+BigInt id : auto increment
+String tenant_slug : unique
+String site_type
+String name
+Text tagline
+Text bio
+Array keywords : text array
+Array categories : text array
+JSONB stats
+JSONB featured_events
+JSONB featured_courses
+Float ranking_score
+TSVECTOR search_vector : generated
+Timestamp synced_at
}
DynamoDB_SyncBus --> Postgres_SearchTable : "DiscoveryIngestWorker<br/>scans and upserts<br/>every 5 min"
pie title Ranking Score Weights - 100 points total
"Member Count - 25" : 25
"Engagement - 25" : 25
"Content Richness - 20" : 20
"Event Activity - 15" : 15
"Profile Completeness - 10" : 10
"Freshness - 5" : 5
graph LR
subgraph "Write Path - per tenant via ExAws"
SYNC["Oban Sync Worker"] -->|"PutItem<br/>full listing + keywords"| DYNAMO[("DynamoDB")]
end
subgraph "Ingestion - marketing site"
DYNAMO -->|"Scan every 5 min"| INGEST["Ingest Worker"]
INGEST -->|"Ecto upsert"| PG[("Postgres")]
end
subgraph "Read Paths - Ecto queries"
BROWSE["Browse All"] -->|"WHERE site_type<br/>ORDER BY ranking_score"| PG
FILTER["Filter Category"] -->|"WHERE categories @> ARRAY"| PG
KEYWORD["Filter Keyword"] -->|"WHERE keywords && ARRAY"| PG
SEARCH["Full-text Search"] -->|"WHERE search_vector @@ tsquery<br/>ORDER BY ts_rank"| PG
FUZZY["Fuzzy Search"] -->|"WHERE similarity > 0.3<br/>pg_trgm"| PG
DETAIL["View Detail"] -->|"WHERE tenant_slug = ?"| PG
end
graph TB
subgraph "Tenant ECS Task - detroitarms"
TASK_ROLE["IAM Task Role"]
end
subgraph "DynamoDB pewpros-discovery"
OWN["pk=LISTING_detroitarms"]
OTHER["pk=LISTING_other-tenant"]
end
TASK_ROLE -->|"PutItem, DeleteItem<br/>Condition: LeadingKeys = LISTING_detroitarms"| OWN
TASK_ROLE -.->|"DENIED by IAM condition"| OTHER
subgraph "Marketing Site - pewpros.com"
MKT_ROLE["IAM Task Role"]
end
MKT_ROLE -->|"Scan - read all items<br/>for ingestion into Postgres"| OWN
MKT_ROLE -->|"Scan - read all items<br/>for ingestion into Postgres"| OTHER
gantt
title Discovery Directory Rollout
dateFormat YYYY-MM-DD
section Phase 1 - Infra and Sync
DynamoDB table and IAM policies :p1a, 2026-04-21, 3d
ex_aws_dynamo and Discovery module :p1b, after p1a, 3d
Postgres table and tsvector indexes :p1f, after p1a, 2d
DiscoverySyncWorker :p1c, after p1b, 2d
DiscoveryIngestWorker :p1g, after p1f, 2d
Admin settings with keywords :p1d, after p1b, 2d
Testing and verification :p1e, after p1g, 2d
section Phase 2 - UI and API
DiscoverLive Index with search :p2a, after p1e, 3d
DiscoverLive Show :p2b, after p2a, 2d
API controller and routes :p2c, after p1e, 2d
Design system and polish :p2d, after p2b, 3d
section Phase 3 - MCP and Mobile
MCP tools :p3a, after p2d, 2d
Admin moderation :p3b, after p2d, 2d
Mobile app integration :p3c, after p3a, 3d
section Phase 4 - Enhanced Search
Geo search with PostGIS :p4a, after p3c, 3d
Faceted filtering :p4b, after p4a, 3d
Search analytics :p4c, after p4b, 2d
graph TB
subgraph "Directory Page"
HEADER["Search Bar: full-text + fuzzy"]
KEYWORDS["Keyword Pills: beginner-friendly / ccw / youth / competition"]
CATS["Category Filter: Firearms Training / Range / Tactical"]
SORT["Sort: Best / Newest / Members / Relevance"]
subgraph "Community Cards Grid"
CARD1["Logo + Name<br/>Tagline<br/>Keywords: ccw, beginner-friendly<br/>342 members / 15 events<br/>Detroit, MI"]
CARD2["Logo + Name<br/>Tagline<br/>Keywords: tactical, competition<br/>128 members / 8 events<br/>Austin, TX"]
CARD3["Logo + Name<br/>Tagline<br/>Keywords: youth, outdoor<br/>89 members / 3 events<br/>Nashville, TN"]
end
PAGINATION["Load More"]
end
HEADER --> KEYWORDS --> CATS --> SORT --> CARD1
CARD1 --- CARD2 --- CARD3 --> PAGINATION
CARD1 -->|Click| DETAIL_PAGE
subgraph "Detail Page"
DETAIL_PAGE["Cover Image + Logo<br/>Community Name<br/>Keyword Tags<br/>Bio and Description<br/>---<br/>Stats: 342 members / 15 events / 8 courses<br/>---<br/>Featured Events<br/>Featured Courses<br/>---<br/>Visit Community Button"]
end