Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@ USGS_BASE_URL=https://earthquake.usgs.gov
AISHUB_API_KEY=
NOAA_BASE_URL=https://api.weather.gov
DISCORD_WEBHOOK_URL=

# Meta agency Business Manager connection
# Store the system user token as a deployment secret, not in source control.
META_GRAPH_BASE_URL=https://graph.facebook.com
META_GRAPH_VERSION=v21.0
META_AGENCY_BUSINESS_ID=
META_SYSTEM_USER_ID=
META_SYSTEM_USER_TOKEN=
17 changes: 17 additions & 0 deletions api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Config struct {
Push PushConfig
Chat ChatConfig
External ExternalConfig
Meta MetaConfig
Observability ObservabilityConfig
}

Expand Down Expand Up @@ -105,6 +106,15 @@ type ExternalConfig struct {
DiscordWebhook string
}

// MetaConfig holds Meta Marketing API configuration.
type MetaConfig struct {
GraphBaseURL string
GraphVersion string
AgencyBusinessID string
SystemUserID string
SystemUserAccessToken string
}

// ObservabilityConfig holds tracing/metrics settings.
type ObservabilityConfig struct {
ServiceName string
Expand Down Expand Up @@ -174,6 +184,13 @@ func Load() (*Config, error) {
LaunchLibraryBaseURL: getEnv("LAUNCH_LIBRARY_BASE_URL", "https://ll.thespacedevs.com/2.2.0"),
DiscordWebhook: getEnv("DISCORD_WEBHOOK_URL", ""),
},
Meta: MetaConfig{
GraphBaseURL: getEnv("META_GRAPH_BASE_URL", "https://graph.facebook.com"),
GraphVersion: getEnv("META_GRAPH_VERSION", "v21.0"),
AgencyBusinessID: getEnv("META_AGENCY_BUSINESS_ID", ""),
SystemUserID: getEnv("META_SYSTEM_USER_ID", ""),
SystemUserAccessToken: getEnv("META_SYSTEM_USER_TOKEN", getEnv("META_SYSTEM_USER_ACCESS_TOKEN", "")),
},
Observability: ObservabilityConfig{
ServiceName: getEnv("OTEL_SERVICE_NAME", "chaseapp-api"),
OTLPEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
Expand Down
213 changes: 213 additions & 0 deletions api/internal/handler/meta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package handler

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"

"chaseapp.tv/api/internal/config"
)

// MetaHandler handles Meta Marketing API endpoints.
type MetaHandler struct {
cfg config.MetaConfig
client *http.Client
logger *slog.Logger
}

// NewMetaHandler creates a new MetaHandler.
func NewMetaHandler(cfg config.MetaConfig, logger *slog.Logger) *MetaHandler {
return &MetaHandler{
cfg: cfg,
client: http.DefaultClient,
logger: logger,
}
}

type metaAdAccount struct {
ID string `json:"id"`
AccountID string `json:"account_id,omitempty"`
Name string `json:"name"`
AccountStatus int `json:"account_status,omitempty"`
Currency string `json:"currency,omitempty"`
TimezoneName string `json:"timezone_name,omitempty"`
}

type metaBusiness struct {
ID string `json:"id"`
Name string `json:"name"`
}

type metaAdAccountCollection struct {
Data []metaAdAccount `json:"data"`
Paging struct {
Next string `json:"next"`
} `json:"paging"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
} `json:"error"`
}

type metaBusinessCollection struct {
Data []metaBusiness `json:"data"`
Paging struct {
Next string `json:"next"`
} `json:"paging"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
} `json:"error"`
}

// ListAdAccounts returns agency-accessible Meta ad accounts.
// GET /api/v1/meta/ad-accounts
func (h *MetaHandler) ListAdAccounts(w http.ResponseWriter, r *http.Request) {
if h.cfg.SystemUserAccessToken == "" {
Error(w, http.StatusServiceUnavailable, "Meta agency connection is not configured")
return
}

fields := "fields=id,account_id,name,account_status,currency,timezone_name&limit=100"
accounts, err := h.fetchAdAccounts(r.Context(), "me/adaccounts?"+fields)
if err != nil {
h.logger.Error("meta ad account fetch failed", slog.Any("error", err))
Error(w, http.StatusBadGateway, "Failed to load Meta ad accounts")
return
}

if h.cfg.SystemUserID != "" {
assigned, err := h.fetchAdAccounts(r.Context(), fmt.Sprintf("%s/assigned_ad_accounts?%s", h.cfg.SystemUserID, fields))
if err != nil {
h.logger.Warn("meta assigned ad account fetch failed", slog.String("system_user_id", h.cfg.SystemUserID), slog.Any("error", err))
}
accounts = append(accounts, assigned...)
}

businesses := []metaBusiness{}
if h.cfg.AgencyBusinessID != "" {
businesses = append(businesses, metaBusiness{ID: h.cfg.AgencyBusinessID})
} else {
businesses, err = h.fetchBusinesses(r.Context(), "me/businesses?fields=id,name&limit=100")
if err != nil {
h.logger.Error("meta business fetch failed", slog.Any("error", err))
Error(w, http.StatusBadGateway, "Failed to load Meta businesses")
return
}
}

for _, business := range businesses {
owned, err := h.fetchAdAccounts(r.Context(), fmt.Sprintf("%s/owned_ad_accounts?%s", business.ID, fields))
if err != nil {
h.logger.Warn("meta owned ad account fetch failed", slog.String("business_id", business.ID), slog.Any("error", err))
}
accounts = append(accounts, owned...)

client, err := h.fetchAdAccounts(r.Context(), fmt.Sprintf("%s/client_ad_accounts?%s", business.ID, fields))
if err != nil {
h.logger.Warn("meta client ad account fetch failed", slog.String("business_id", business.ID), slog.Any("error", err))
}
accounts = append(accounts, client...)
}

JSON(w, http.StatusOK, map[string]any{
"data": dedupeMetaAdAccounts(accounts),
})
}

func (h *MetaHandler) fetchAdAccounts(ctx context.Context, path string) ([]metaAdAccount, error) {
items := []metaAdAccount{}
nextURL := h.metaURL(path)

for nextURL != "" {
var payload metaAdAccountCollection
err := h.fetchMetaPage(ctx, nextURL, &payload)
if err != nil {
return nil, err
}

items = append(items, payload.Data...)
nextURL = payload.Paging.Next
}

return items, nil
}

func (h *MetaHandler) fetchBusinesses(ctx context.Context, path string) ([]metaBusiness, error) {
items := []metaBusiness{}
nextURL := h.metaURL(path)

for nextURL != "" {
var payload metaBusinessCollection
err := h.fetchMetaPage(ctx, nextURL, &payload)
if err != nil {
return nil, err
}

items = append(items, payload.Data...)
nextURL = payload.Paging.Next
}

return items, nil
}

func (h *MetaHandler) fetchMetaPage(ctx context.Context, pageURL string, payload any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
if err != nil {
return err
}

resp, err := h.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
return err
}

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("meta api status: %d", resp.StatusCode)
}

return nil
}

func (h *MetaHandler) metaURL(path string) string {
base := strings.TrimRight(h.cfg.GraphBaseURL, "/")
version := strings.Trim(h.cfg.GraphVersion, "/")
path = strings.TrimLeft(path, "/")
separator := "?"
if strings.Contains(path, "?") {
separator = "&"
}

return fmt.Sprintf("%s/%s/%s%saccess_token=%s", base, version, path, separator, url.QueryEscape(h.cfg.SystemUserAccessToken))
}

func dedupeMetaAdAccounts(accounts []metaAdAccount) []metaAdAccount {
seen := map[string]metaAdAccount{}
for _, account := range accounts {
if account.ID == "" {
continue
}
seen[account.ID] = account
}

out := make([]metaAdAccount, 0, len(seen))
for _, account := range seen {
out = append(out, account)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out
}
5 changes: 5 additions & 0 deletions api/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Server struct {
authHandler *handler.AuthHandler
webhookHandler *handler.WebhookHandler
searchHandler *handler.SearchHandler
metaHandler *handler.MetaHandler

// Realtime
publisher *realtime.Publisher
Expand Down Expand Up @@ -129,6 +130,7 @@ func New(cfg *config.Config, logger *slog.Logger, pool *pgxpool.Pool) (*Server,
authHandler: handler.NewAuthHandler(chatSigner, logger),
webhookHandler: webhookHandler,
searchHandler: handler.NewSearchHandler(typesenseClient, logger),
metaHandler: handler.NewMetaHandler(cfg.Meta, logger),
subscriber: subscriber,

// Workers
Expand Down Expand Up @@ -221,6 +223,9 @@ func (s *Server) setupRoutes() {

// Search
api.HandleFunc("/search", s.searchHandler.Search).Methods(http.MethodGet)

// Meta
api.HandleFunc("/meta/ad-accounts", s.metaHandler.ListAdAccounts).Methods(http.MethodGet)
}

// readinessCheck verifies database connectivity.
Expand Down
3 changes: 3 additions & 0 deletions k8s/base/api-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ data:
NTFY_URL: "https://ntfy.local"
CHAT_TOKEN_ISSUER: "chaseapp"
CHAT_TOKEN_AUDIENCE: "chat"
META_GRAPH_BASE_URL: "https://graph.facebook.com"
META_GRAPH_VERSION: "v21.0"
META_SYSTEM_USER_ID: "61580721635230"
1 change: 1 addition & 0 deletions k8s/base/api-secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ stringData:
FCM_KEY_PATH: ""
SAFARI_PUSH_ID: ""
SAFARI_WEB_SERVICE_URL: ""
META_SYSTEM_USER_TOKEN: ""
26 changes: 26 additions & 0 deletions web/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,20 @@
>
Settings
</nuxt-link>
<nuxt-link
to="/campaign-builder"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="menuitem"
>
Campaign Builder
</nuxt-link>
<nuxt-link
to="/creative-uploader"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="menuitem"
>
Creative Uploader
</nuxt-link>
<nuxt-link
to="/profile"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
Expand Down Expand Up @@ -336,6 +350,18 @@
>
Settings
</nuxt-link>
<nuxt-link
to="/campaign-builder"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-800 hover:text-white hover:bg-gray-700"
>
Campaign Builder
</nuxt-link>
<nuxt-link
to="/creative-uploader"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-800 hover:text-white hover:bg-gray-700"
>
Creative Uploader
</nuxt-link>
<nuxt-link
to="/profile"
class="block px-3 py-2 rounded-md text-base font-medium text-gray-800 hover:text-white hover:bg-gray-700"
Expand Down
Loading