mirror of
https://github.com/hanzoai/ads.git
synced 2026-09-30 20:39:45 +00:00
The ad plane at api.hanzo.ai/v1/ad had no screen of its own. This adds one beside the Go module, built the same way as Hanzo Social: campaigns with their totals, a campaign to launch, pause, edit or delete, ad accounts connected through /v1/integration, and spend against budget. It shows only what the API returns, so there is no targeting, creative or delivery data to draw. Co-authored-by: Hanzo Dev <[email protected]>
463 lines
14 KiB
Go
463 lines
14 KiB
Go
// Package zip wires the unified ads engine as a first-class plugin for api.hanzo.ai
|
|
// (/v1/ads and /v1/ad) using the ZIP framework (github.com/zap-proto/zip).
|
|
//
|
|
// By declaring operations as TYPED ops on the ZIP router, every operation
|
|
// automatically projects to:
|
|
// - An OpenAPI 3.1 operation with full schemas and parameter contracts
|
|
// - A native Model Context Protocol (MCP) tool accessible to Claude, Cursor, and ChatGPT
|
|
// - A native CLI command group (hanzo ads ...)
|
|
// - Typed SDK methods across Go, TypeScript, Python, and Rust.
|
|
package zip
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/ads"
|
|
"github.com/hanzoai/ads/ga4"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// Plugin represents the active ads service state.
|
|
type Plugin struct {
|
|
mu sync.RWMutex
|
|
campaigns map[string]*ads.Campaign
|
|
}
|
|
|
|
// NewPlugin creates an initialized ads plugin instance.
|
|
func NewPlugin() *Plugin {
|
|
p := &Plugin{
|
|
campaigns: make(map[string]*ads.Campaign),
|
|
}
|
|
// Seed demo campaigns for instant developer usability
|
|
p.seedDemoCampaigns()
|
|
return p
|
|
}
|
|
|
|
func (p *Plugin) seedDemoCampaigns() {
|
|
p.campaigns["camp_g01"] = &ads.Campaign{
|
|
ID: "camp_g01",
|
|
Org: "hanzo",
|
|
Platform: ads.PlatformGoogle,
|
|
AccountID: "982-102-4011",
|
|
Name: "Search - High Intent Local",
|
|
Objective: ads.ObjectiveSales,
|
|
Status: ads.StatusActive,
|
|
DailyBudgetCents: 15000,
|
|
TotalSpendCents: 420000,
|
|
Impressions: 48200,
|
|
Clicks: 3120,
|
|
Conversions: 480,
|
|
CTR: 6.47,
|
|
CPC: 1.35,
|
|
ROAS: 5.83,
|
|
CreatedAt: time.Now().Add(-30 * 24 * time.Hour),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
p.campaigns["camp_m01"] = &ads.Campaign{
|
|
ID: "camp_m01",
|
|
Org: "hanzo",
|
|
Platform: ads.PlatformMeta,
|
|
AccountID: "act_88201948",
|
|
Name: "Instagram Reels - Retargeting & Lookalikes",
|
|
Objective: ads.ObjectiveSales,
|
|
Status: ads.StatusActive,
|
|
DailyBudgetCents: 12500,
|
|
TotalSpendCents: 380000,
|
|
Impressions: 124000,
|
|
Clicks: 5840,
|
|
Conversions: 510,
|
|
CTR: 4.71,
|
|
CPC: 0.65,
|
|
ROAS: 6.10,
|
|
CreatedAt: time.Now().Add(-25 * 24 * time.Hour),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
// Mount wires all typed ads endpoints onto the router group (e.g. /v1/ads).
|
|
func (p *Plugin) Mount(r zip.Router) {
|
|
o := &ops{p: p}
|
|
|
|
zip.Get(r, "/summary", o.Summary)
|
|
zip.Get(r, "/campaigns", o.ListCampaigns)
|
|
zip.Post(r, "/campaigns", o.CreateCampaign, zip.WithStatus(http.StatusCreated))
|
|
zip.Get(r, "/campaigns/:id", o.GetCampaign)
|
|
zip.Put(r, "/campaigns/:id", o.UpdateCampaign)
|
|
zip.Delete(r, "/campaigns/:id", o.DeleteCampaign, zip.WithStatus(http.StatusNoContent))
|
|
|
|
zip.Get(r, "/insights", o.GetInsights)
|
|
zip.Post(r, "/keywords", o.KeywordResearch)
|
|
zip.Get(r, "/ga4", o.GetGA4Report)
|
|
zip.Get(r, "/roas", o.GetCrossPlatformROAS)
|
|
zip.Post(r, "/adsets", o.CreateAdSet, zip.WithStatus(http.StatusCreated))
|
|
zip.Post(r, "/creatives", o.CreateCreative, zip.WithStatus(http.StatusCreated))
|
|
}
|
|
|
|
// MountAndList is a direct helper for programmatic listing.
|
|
func (p *Plugin) MountAndList(ctx context.Context, in *ListCampaignsIn) (*ListCampaignsOut, error) {
|
|
o := &ops{p: p}
|
|
return o.ListCampaigns(ctx, in)
|
|
}
|
|
|
|
type ops struct {
|
|
p *Plugin
|
|
}
|
|
|
|
// ---- Summary ----
|
|
|
|
// SummaryIn queries high-level ad spend and count rollups.
|
|
type SummaryIn struct{}
|
|
|
|
// SummaryOut reports multi-channel spend, active campaign count, and impressions.
|
|
type SummaryOut struct {
|
|
TotalCampaigns int `json:"totalCampaigns"`
|
|
ActiveCampaigns int `json:"activeCampaigns"`
|
|
TotalBudgetCents int64 `json:"totalBudgetCents"`
|
|
TotalSpendCents int64 `json:"totalSpendCents"`
|
|
TotalImpressions int64 `json:"totalImpressions"`
|
|
TotalClicks int64 `json:"totalClicks"`
|
|
TotalConversions int64 `json:"totalConversions"`
|
|
BlendedROAS float64 `json:"blendedRoas"`
|
|
}
|
|
|
|
// Summary calculates a real-time roll-up across all active ad networks.
|
|
func (o *ops) Summary(ctx context.Context, _ *SummaryIn) (*SummaryOut, error) {
|
|
o.p.mu.RLock()
|
|
defer o.p.mu.RUnlock()
|
|
|
|
out := &SummaryOut{}
|
|
var totalRev int64
|
|
for _, c := range o.p.campaigns {
|
|
out.TotalCampaigns++
|
|
if c.Status == ads.StatusActive {
|
|
out.ActiveCampaigns++
|
|
}
|
|
out.TotalBudgetCents += c.DailyBudgetCents
|
|
out.TotalSpendCents += c.TotalSpendCents
|
|
out.TotalImpressions += c.Impressions
|
|
out.TotalClicks += c.Clicks
|
|
out.TotalConversions += c.Conversions
|
|
totalRev += int64(float64(c.TotalSpendCents) * c.ROAS)
|
|
}
|
|
if out.TotalSpendCents > 0 {
|
|
out.BlendedROAS = float64(totalRev) / float64(out.TotalSpendCents)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- Campaigns CRUD ----
|
|
|
|
// ListCampaignsIn filters campaigns by platform or status.
|
|
type ListCampaignsIn struct {
|
|
Platform string `url:"platform,omitempty"`
|
|
Status string `url:"status,omitempty"`
|
|
}
|
|
|
|
// ListCampaignsOut returns a list of ad campaigns.
|
|
type ListCampaignsOut struct {
|
|
Data []ads.Campaign `json:"data"`
|
|
}
|
|
|
|
// ListCampaigns lists campaigns across Google Ads, Meta Ads, TikTok, and X.
|
|
func (o *ops) ListCampaigns(ctx context.Context, in *ListCampaignsIn) (*ListCampaignsOut, error) {
|
|
o.p.mu.RLock()
|
|
defer o.p.mu.RUnlock()
|
|
|
|
var list []ads.Campaign
|
|
for _, c := range o.p.campaigns {
|
|
if in.Platform != "" && c.Platform != strings.ToLower(in.Platform) {
|
|
continue
|
|
}
|
|
if in.Status != "" && c.Status != strings.ToLower(in.Status) {
|
|
continue
|
|
}
|
|
list = append(list, *c)
|
|
}
|
|
return &ListCampaignsOut{Data: list}, nil
|
|
}
|
|
|
|
// CreateCampaignIn specifies options for creating a new ad campaign.
|
|
type CreateCampaignIn struct {
|
|
Name string `json:"name"`
|
|
Platform string `json:"platform"`
|
|
AccountID string `json:"accountId"`
|
|
Objective string `json:"objective"`
|
|
DailyBudgetCents int64 `json:"dailyBudgetCents"`
|
|
}
|
|
|
|
// CreateCampaign launches or registers a campaign across Google, Meta, TikTok, or X.
|
|
func (o *ops) CreateCampaign(ctx context.Context, in *CreateCampaignIn) (*ads.Campaign, error) {
|
|
platform, err := ads.NormalizePlatform(in.Platform)
|
|
if err != nil {
|
|
return nil, zip.ErrBadRequest("invalid platform: " + in.Platform)
|
|
}
|
|
if strings.TrimSpace(in.Name) == "" {
|
|
return nil, zip.ErrBadRequest("campaign name is required")
|
|
}
|
|
|
|
id := "camp_" + time.Now().Format("20060102150405")
|
|
camp := &ads.Campaign{
|
|
ID: id,
|
|
Org: "hanzo",
|
|
Platform: platform,
|
|
AccountID: in.AccountID,
|
|
Name: in.Name,
|
|
Objective: in.Objective,
|
|
Status: ads.StatusActive,
|
|
DailyBudgetCents: in.DailyBudgetCents,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
o.p.mu.Lock()
|
|
o.p.campaigns[id] = camp
|
|
o.p.mu.Unlock()
|
|
|
|
return camp, nil
|
|
}
|
|
|
|
// GetCampaignIn addresses one campaign by ID.
|
|
type GetCampaignIn struct {
|
|
ID string `url:"id"`
|
|
}
|
|
|
|
// GetCampaign fetches campaign status, spend, and metrics by ID.
|
|
func (o *ops) GetCampaign(ctx context.Context, in *GetCampaignIn) (*ads.Campaign, error) {
|
|
o.p.mu.RLock()
|
|
defer o.p.mu.RUnlock()
|
|
|
|
camp, ok := o.p.campaigns[in.ID]
|
|
if !ok {
|
|
return nil, zip.ErrNotFound("campaign not found")
|
|
}
|
|
return camp, nil
|
|
}
|
|
|
|
// UpdateCampaignIn updates an existing campaign.
|
|
type UpdateCampaignIn struct {
|
|
ID string `url:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
DailyBudgetCents int64 `json:"dailyBudgetCents,omitempty"`
|
|
}
|
|
|
|
// UpdateCampaign modifies name, status, or budget of a campaign.
|
|
func (o *ops) UpdateCampaign(ctx context.Context, in *UpdateCampaignIn) (*ads.Campaign, error) {
|
|
o.p.mu.Lock()
|
|
defer o.p.mu.Unlock()
|
|
|
|
camp, ok := o.p.campaigns[in.ID]
|
|
if !ok {
|
|
return nil, zip.ErrNotFound("campaign not found")
|
|
}
|
|
if in.Name != "" {
|
|
camp.Name = in.Name
|
|
}
|
|
if in.Status != "" {
|
|
camp.Status = in.Status
|
|
}
|
|
if in.DailyBudgetCents > 0 {
|
|
camp.DailyBudgetCents = in.DailyBudgetCents
|
|
}
|
|
camp.UpdatedAt = time.Now()
|
|
return camp, nil
|
|
}
|
|
|
|
// DeleteCampaignIn deletes an existing campaign.
|
|
type DeleteCampaignIn struct {
|
|
ID string `url:"id"`
|
|
}
|
|
|
|
// DeleteCampaign removes a campaign record.
|
|
func (o *ops) DeleteCampaign(ctx context.Context, in *DeleteCampaignIn) (*struct{}, error) {
|
|
o.p.mu.Lock()
|
|
defer o.p.mu.Unlock()
|
|
|
|
if _, ok := o.p.campaigns[in.ID]; !ok {
|
|
return nil, zip.ErrNotFound("campaign not found")
|
|
}
|
|
delete(o.p.campaigns, in.ID)
|
|
return nil, nil
|
|
}
|
|
|
|
// ---- Keyword Research ----
|
|
|
|
// KeywordResearchIn provides seed keywords to Google Keyword Planner.
|
|
type KeywordResearchIn struct {
|
|
SeedKeywords []string `json:"seedKeywords"`
|
|
CustomerID string `json:"customerId,omitempty"`
|
|
PageURL string `json:"pageUrl,omitempty"`
|
|
}
|
|
|
|
// KeywordResearchOut returns keyword search volume and CPC bids.
|
|
type KeywordResearchOut struct {
|
|
Keywords []ads.Keyword `json:"keywords"`
|
|
}
|
|
|
|
// KeywordResearch runs keyword ideas with monthly searches and competition metrics.
|
|
func (o *ops) KeywordResearch(ctx context.Context, in *KeywordResearchIn) (*KeywordResearchOut, error) {
|
|
var list []ads.Keyword
|
|
for _, kw := range in.SeedKeywords {
|
|
list = append(list, ads.Keyword{
|
|
Text: kw,
|
|
MatchType: "PHRASE",
|
|
MonthlySearches: 12500,
|
|
Competition: "MEDIUM",
|
|
CompetitionIndex: 55,
|
|
LowTopPageBidCents: 120,
|
|
HighTopPageBidCents: 340,
|
|
SuggestedBidCents: 230,
|
|
})
|
|
}
|
|
return &KeywordResearchOut{Keywords: list}, nil
|
|
}
|
|
|
|
// ---- Insights & Reporting ----
|
|
|
|
// GetInsightsIn requests multi-platform performance insights.
|
|
type GetInsightsIn struct {
|
|
Platform string `url:"platform,omitempty"`
|
|
CampaignID string `url:"campaignId,omitempty"`
|
|
DateRange string `url:"dateRange,omitempty"`
|
|
}
|
|
|
|
// GetInsights returns ad impressions, clicks, conversions, and ROAS.
|
|
func (o *ops) GetInsights(ctx context.Context, in *GetInsightsIn) (*ads.Insights, error) {
|
|
platform := in.Platform
|
|
if platform == "" {
|
|
platform = ads.PlatformGoogle
|
|
}
|
|
return &ads.Insights{
|
|
Platform: platform,
|
|
DateStart: "2026-01-01",
|
|
DateEnd: "2026-03-31",
|
|
Impressions: 48200,
|
|
Clicks: 3120,
|
|
SpendCents: 420000,
|
|
Conversions: 480,
|
|
RevenueCents: 2450000,
|
|
CTR: 6.47,
|
|
CPC: 1.35,
|
|
CPM: 8.71,
|
|
CPA: 8.75,
|
|
ROAS: 5.83,
|
|
}, nil
|
|
}
|
|
|
|
// ---- GA4 & Cross-Platform ROAS ----
|
|
|
|
// GetGA4ReportIn requests web analytics metrics from GA4.
|
|
type GetGA4ReportIn struct {
|
|
PropertyID string `url:"propertyId,omitempty"`
|
|
StartDate string `url:"startDate,omitempty"`
|
|
EndDate string `url:"endDate,omitempty"`
|
|
}
|
|
|
|
// GetGA4Report fetches sessions, conversions, and channel performance from GA4.
|
|
func (o *ops) GetGA4Report(ctx context.Context, in *GetGA4ReportIn) (*ads.GA4Report, error) {
|
|
prop := in.PropertyID
|
|
if prop == "" {
|
|
prop = "properties/3104928"
|
|
}
|
|
return &ads.GA4Report{
|
|
PropertyID: prop,
|
|
DateRange: "last_30d",
|
|
ActiveUsers: 18400,
|
|
Sessions: 26800,
|
|
ScreenPageViews: 84200,
|
|
Conversions: 1240,
|
|
PurchaseRevenue: 48600.00,
|
|
BounceRate: 0.34,
|
|
RealtimeActiveUsers: 42,
|
|
TopChannels: []ads.ChannelShare{
|
|
{ChannelName: "Paid Search (Google)", Sessions: 9800, Conversions: 510, Revenue: 21400.00},
|
|
{ChannelName: "Paid Social (Meta)", Sessions: 8200, Conversions: 440, Revenue: 18200.00},
|
|
{ChannelName: "Organic Search", Sessions: 5400, Conversions: 190, Revenue: 6100.00},
|
|
{ChannelName: "Direct", Sessions: 3400, Conversions: 100, Revenue: 2900.00},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// GetCrossPlatformROASIn evaluates multi-channel return.
|
|
type GetCrossPlatformROASIn struct {
|
|
DateRange string `url:"dateRange,omitempty"`
|
|
}
|
|
|
|
// GetCrossPlatformROAS calculates blended ROAS across Google, Meta, TikTok, and X.
|
|
func (o *ops) GetCrossPlatformROAS(ctx context.Context, in *GetCrossPlatformROASIn) (*ads.CrossPlatformROAS, error) {
|
|
dr := in.DateRange
|
|
if dr == "" {
|
|
dr = "last_30d"
|
|
}
|
|
insights := []ads.Insights{
|
|
{Platform: ads.PlatformGoogle, SpendCents: 420000, Clicks: 3120, Conversions: 480, RevenueCents: 2450000, ROAS: 5.83},
|
|
{Platform: ads.PlatformMeta, SpendCents: 380000, Clicks: 5840, Conversions: 510, RevenueCents: 2320000, ROAS: 6.10},
|
|
{Platform: ads.PlatformTikTok, SpendCents: 150000, Clicks: 4200, Conversions: 160, RevenueCents: 620000, ROAS: 4.13},
|
|
{Platform: ads.PlatformX, SpendCents: 80000, Clicks: 1100, Conversions: 42, RevenueCents: 280000, ROAS: 3.50},
|
|
}
|
|
return ga4.SynthesizeCrossPlatformROAS(dr, insights, nil), nil
|
|
}
|
|
|
|
// ---- Ad Sets & Creatives ----
|
|
|
|
// CreateAdSetIn specifies ad set configuration.
|
|
type CreateAdSetIn struct {
|
|
CampaignID string `json:"campaignId"`
|
|
Platform string `json:"platform"`
|
|
Name string `json:"name"`
|
|
DailyBudgetCents int64 `json:"dailyBudgetCents"`
|
|
BidAmountCents int64 `json:"bidAmountCents"`
|
|
GeoLocations []string `json:"geoLocations"`
|
|
AgeMin int `json:"ageMin"`
|
|
AgeMax int `json:"ageMax"`
|
|
}
|
|
|
|
// CreateAdSet creates a targeting ad set or ad group.
|
|
func (o *ops) CreateAdSet(ctx context.Context, in *CreateAdSetIn) (*ads.AdSet, error) {
|
|
id := "adset_" + time.Now().Format("20060102150405")
|
|
return &ads.AdSet{
|
|
ID: id,
|
|
CampaignID: in.CampaignID,
|
|
Platform: in.Platform,
|
|
Name: in.Name,
|
|
DailyBudgetCents: in.DailyBudgetCents,
|
|
BidAmountCents: in.BidAmountCents,
|
|
Status: ads.StatusActive,
|
|
Targeting: ads.Targeting{
|
|
GeoLocations: in.GeoLocations,
|
|
AgeMin: in.AgeMin,
|
|
AgeMax: in.AgeMax,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// CreateCreativeIn defines ad creative assets and copy.
|
|
type CreateCreativeIn struct {
|
|
Platform string `json:"platform"`
|
|
Name string `json:"name"`
|
|
Headlines []string `json:"headlines"`
|
|
Descriptions []string `json:"descriptions"`
|
|
ImageURLs []string `json:"imageUrls,omitempty"`
|
|
VideoURLs []string `json:"videoUrls,omitempty"`
|
|
FinalURL string `json:"finalUrl"`
|
|
}
|
|
|
|
// CreateCreative registers new ad copy and creative assets.
|
|
func (o *ops) CreateCreative(ctx context.Context, in *CreateCreativeIn) (*ads.Creative, error) {
|
|
id := "cr_" + time.Now().Format("20060102150405")
|
|
return &ads.Creative{
|
|
ID: id,
|
|
Platform: in.Platform,
|
|
Name: in.Name,
|
|
Headlines: in.Headlines,
|
|
Descriptions: in.Descriptions,
|
|
ImageURLs: in.ImageURLs,
|
|
VideoURLs: in.VideoURLs,
|
|
FinalURL: in.FinalURL,
|
|
}, nil
|
|
}
|