From 19237a824e9130d8b3660b89cf2bf4d1a6df3663 Mon Sep 17 00:00:00 2001 From: Dominykas Peleckis Date: Fri, 12 Jun 2026 11:10:54 +0300 Subject: [PATCH] Add agency Meta creative uploader --- api/.env.example | 8 + api/internal/config/config.go | 17 + api/internal/handler/meta.go | 213 +++++++++ api/internal/server/server.go | 5 + k8s/base/api-configmap.yaml | 3 + k8s/base/api-secret.yaml | 1 + web/layouts/default.vue | 26 ++ web/pages/campaign-builder.vue | 780 ++++++++++++++++++++++++++++++++ web/pages/creative-uploader.vue | 738 ++++++++++++++++++++++++++++++ 9 files changed, 1791 insertions(+) create mode 100644 api/internal/handler/meta.go create mode 100644 web/pages/campaign-builder.vue create mode 100644 web/pages/creative-uploader.vue diff --git a/api/.env.example b/api/.env.example index 9782da3..afbffed 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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= diff --git a/api/internal/config/config.go b/api/internal/config/config.go index bf981ff..0f3c609 100644 --- a/api/internal/config/config.go +++ b/api/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Push PushConfig Chat ChatConfig External ExternalConfig + Meta MetaConfig Observability ObservabilityConfig } @@ -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 @@ -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", ""), diff --git a/api/internal/handler/meta.go b/api/internal/handler/meta.go new file mode 100644 index 0000000..f248f48 --- /dev/null +++ b/api/internal/handler/meta.go @@ -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 +} diff --git a/api/internal/server/server.go b/api/internal/server/server.go index 3876b21..1851442 100644 --- a/api/internal/server/server.go +++ b/api/internal/server/server.go @@ -43,6 +43,7 @@ type Server struct { authHandler *handler.AuthHandler webhookHandler *handler.WebhookHandler searchHandler *handler.SearchHandler + metaHandler *handler.MetaHandler // Realtime publisher *realtime.Publisher @@ -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 @@ -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. diff --git a/k8s/base/api-configmap.yaml b/k8s/base/api-configmap.yaml index 0ea0a74..5d68322 100644 --- a/k8s/base/api-configmap.yaml +++ b/k8s/base/api-configmap.yaml @@ -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" diff --git a/k8s/base/api-secret.yaml b/k8s/base/api-secret.yaml index 05f5e56..1d702cd 100644 --- a/k8s/base/api-secret.yaml +++ b/k8s/base/api-secret.yaml @@ -21,3 +21,4 @@ stringData: FCM_KEY_PATH: "" SAFARI_PUSH_ID: "" SAFARI_WEB_SERVICE_URL: "" + META_SYSTEM_USER_TOKEN: "" diff --git a/web/layouts/default.vue b/web/layouts/default.vue index dad750d..60c8c64 100644 --- a/web/layouts/default.vue +++ b/web/layouts/default.vue @@ -151,6 +151,20 @@ > Settings + + Campaign Builder + + + Creative Uploader + Settings + + Campaign Builder + + + Creative Uploader + +
+
+

+ Tools +

+
+
+

Campaign Builder

+

+ Build a ready-to-review GetChase growth campaign from an event, + region, budget, and audience goal. +

+
+ +
+
+ +
+
+
+

Campaign inputs

+ Draft only +
+ + + +
+ +

{{ metaConnectionStatus }}

+
+ + + + + + + +
+ + + +
+ + + +
+

Audience setup

+ AI-assisted +
+ +
+ + + +
+ + + + + +