Files
zeekayandHanzo Dev 4c1cde7cad Hanzo Ads runs paid campaigns from a web app
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]>
2026-09-15 11:16:20 -07:00

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
}