content

package
v1.801.459 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

Documentation

Overview

Package content is marketing content from draft to published, on every channel.

The Hanzo agentic-marketing lane: the content loop (generate → CMS → review → approve → publish → distribute) built natively on the framework DocType engine, the ONE Go-native replacement for the bespoke karma Python scripts, multi-tenant for ANY brand (org = tenant, project = brand/site sub-scope).

It follows the knowledge lane's proven shape — a framework MODULE (DocType fixtures + lifecycle hook) PLUS a thin control-plane subsystem — NOT a second store:

  • The content model IS framework DocTypes in module "marketing" (this file): Campaign, SocialPost, Asset. A "collection" is a DocType, a content item is a framework document, media is an Attach URL, and the editorial lifecycle is the ONE `status` Select field (lifecycle.go). All CRUD, tenancy, permissions, and install are the framework's already-live generic surface (/v1/framework/*) — content adds NO parallel CRUD.
  • The control-plane (content.go) mounts /v1/content/* for the three things the generic engine cannot be: the cross-DocType board (aggregate read), the side-effecting lifecycle transition, and the generate/publish orchestration that reaches out to zen5 (deps.AI), studio, and hanzoai/social.

The marketing module's `status` is therefore the ONE publishable-content lifecycle in service. apps/cms declares a second content model (Page/Post/Article) on its own Draft/Published field, but no binary imports that package, so its init never runs, the "cms" module is never registered, and no org can install it.

Index

Constants

View Source
const (
	DocTypeCampaign   = "Campaign"
	DocTypeSocialPost = "SocialPost"
	DocTypeAsset      = "Asset"
)

The marketing DocType names. Single-word (no spaces) so a name is a clean URL path segment under /v1/content/:doctype and a clean Link `options` target.

View Source
const (
	StatusDraft     = "draft"     // authored / generated, not yet submitted
	StatusInReview  = "in_review" // submitted for editorial/agent review
	StatusApproved  = "approved"  // signed off, ready to schedule or publish
	StatusQueued    = "queued"    // handed to distribution (scheduled / in-flight)
	StatusPublished = "published" // live on site + confirmed on channels
	StatusArchived  = "archived"  // retired / unpublished
)

lifecycle.go is the ONE marketing-content state machine — a pure value, defined once, read by two separated concerns:

  • the framework before_save HOOK (hooks.go) enforces edge legality on EVERY write to a publishable DocType, including a raw PUT /v1/framework/:doctype — so an illegal status jump is impossible at the storage boundary; and
  • the /v1/content transition ENDPOINT (content.go) composes the same table with the orchestration side effects (fan-out to distribution on queued/published).

Decomplected (values, not places): the RULE lives here as data; ENFORCEMENT (the hook) and ORCHESTRATION (the endpoint) are distinct consumers that both read it. Neither owns the rule; changing an edge is a one-line change in one map.

The states subsume the bespoke karma lifecycle (catalog draft→approved→published, marketing draft→queued→published) and add the missing editorial gate `in_review`:

draft ──▶ in_review ──▶ approved ──▶ queued ──▶ published
  ▲          │             │           │            │
  └──────────┴─────────────┘           │            ▼
                (send back)            └────────▶ archived ──▶ draft (reopen)
View Source
const Module = "marketing"

Module is the framework module tag every marketing DocType carries. The console's marketing surface is the generic DocType renderer scoped to this module; installing it (POST /v1/framework/modules/marketing/install) ensures these DocTypes exist in the caller's org.

View Source
const RoleContentEditor = "Content Editor"

RoleContentEditor is the editorial role the marketing DocTypes grant read/write/ create/delete. The org owner (System Manager, seeded trust-on-first-use) assigns it via /v1/framework/roles; a role-less member stays denied (secure by default). It is the SAME role name the cms lane grants, so one grant covers both content lanes. Transitioning is a status write, so it needs no extra right.

View Source
const StatusField = "status"

StatusField is the DocField name every publishable content DocType carries. It is the SINGLE lifecycle column; framework's own docstatus (0/1/2) is deliberately left unused (IsSubmittable=false) so there is exactly one lifecycle, here.

Variables

This section is empty.

Functions

func CanTransition

func CanTransition(from, to string) bool

CanTransition reports whether from→to is a legal edge. A no-op (from==to) is legal (an idempotent write that does not change status). An unknown from/to is illegal.

func DocTypes

func DocTypes() []framework.DocType

DocTypes returns the canonical marketing content model an org gets when it installs the "marketing" lane. Link targets are resolved at document write (not at define), so cross-references within the set are order-independent.

func IsLive

func IsLive(s string) bool

IsLive reports whether a document in state s is publicly readable (the site reads only live content). Exactly StatusPublished today; kept a predicate so a future "live" state is a one-line change, not a scattered string compare.

func IsStatus

func IsStatus(s string) bool

IsStatus reports whether s is a defined lifecycle state.

func Mount

func Mount(app cloud.Router, deps cloud.Deps) error

Mount wires the content control-plane onto app. It is a "complex" mount (a package global for the exported ops the connector calls), so it builds the Service value directly per the cloud.Service convention.

func Shutdown

func Shutdown() error

Shutdown releases the mounted singleton. Idempotent; content owns no store, so this only clears the pointer (kept for symmetry with the store-owning lanes + test hygiene).

func Transitions

func Transitions(from string) []string

Transitions returns the legal successors of `from` (nil for an unknown state), a copy so a caller cannot mutate the rule. The console renders per-item action buttons from this.

Types

type CatalogAssetResult

type CatalogAssetResult struct {
	Org     string `json:"org"`
	Slug    string `json:"slug"`
	Name    string `json:"name,omitempty"`
	Created bool   `json:"created"`
	Skipped string `json:"skipped,omitempty"` // "exists" | "not_installed" | "not_configured"
}

CatalogAssetResult reports the outcome of EnsureCatalogAsset. Exactly one of Created or a non-empty Skipped is set; Name is the created or pre-existing Asset (empty on skip).

func EnsureCatalogAsset

func EnsureCatalogAsset(ctx context.Context, org, slug string) (CatalogAssetResult, error)

EnsureCatalogAsset renders (if needed) the ecom Asset for a commerce product, keyed by design == slug. org MUST be the validated tenant. It returns a benign result with nil error for every foreseeable skip — an org without the marketing lane, a product that already has a non-archived ecom asset, or a studio that is not configured/reachable — so a driving consumer ACKs and moves on. It returns a non-nil error ONLY for a genuine store fault the consumer should retry.

type Channel

type Channel struct {
	ID       string `json:"id"`       // the social integration id to target in a post
	Provider string `json:"provider"` // "x" | "instagram" | "tiktok" | ...
	Name     string `json:"name"`
	Disabled bool   `json:"disabled"`
}

Channel is one connected distribution channel for a brand (a social integration).

type ChannelResult

type ChannelResult struct {
	Channel    string `json:"channel"`              // the social integration id targeted
	Provider   string `json:"provider,omitempty"`   // "x" | "instagram" | ... when known
	Status     string `json:"status"`               // "distributed" | "scheduled" | "failed"
	ExternalID string `json:"externalId,omitempty"` // social post id, when it went out
	Error      string `json:"error,omitempty"`      // short reason, when it failed
}

ChannelResult is the honest outcome for ONE channel of a distribution. A channel that posted carries its ExternalID; a channel that failed carries a short Error. This is what lets a partial fan-out (some channels ok, some down) report the truth instead of a 5xx.

type DistributeRequest

type DistributeRequest struct {
	Content     string
	Media       []MediaRef
	Channels    []string
	ScheduleAt  string
	Distributed map[string]bool
}

DistributeRequest is the provider-agnostic post the Distributor sends. Content is the caption/copy; Channels are provider ids (or integration ids) to target; ScheduleAt "" means publish now, otherwise an ISO-8601 future time. Distributed is the idempotency guard: the set of channel ids ALREADY posted for this item (from the doc's external_ids). A Distributor MUST skip any target already in this set so a re-transition or a retry can never post the same channel twice.

type DistributeResult

type DistributeResult struct {
	Scheduled   bool
	ExternalIDs map[string]string // channel id → external post id (successes only)
	Channels    []ChannelResult   // per-channel honest status (ok + failed)
}

DistributeResult is what the edge returns: whether it was scheduled (vs posted now), the per-channel external post ids to record for reconciliation, and the honest per-channel outcome. ExternalIDs holds ONLY the channels that succeeded (channel id → external post id); Channels reports every attempted channel, including failures, so partial success is never flattened into a blanket error.

type Distributor

type Distributor interface {
	Channels(ctx context.Context, org string) ([]Channel, error)
	Publish(ctx context.Context, org string, req DistributeRequest) (DistributeResult, error)
}

Distributor is the channel edge. Channels lists a brand's connected channels; Publish posts (or schedules) one item to them. Implementations are the ONLY place a social provider/API is touched.

type GenerateInput

type GenerateInput struct {
	DocType  string `json:"doctype"`                // Campaign | SocialPost | Asset
	Title    string `json:"title,omitempty"`        // optional explicit title
	Brief    string `json:"brief,omitempty"`        // the brief/goal driving copy generation
	Product  string `json:"product,omitempty"`      // commerce product handle (copy context)
	Design   string `json:"design,omitempty"`       // studio design slug (asset source)
	Channels string `json:"channels,omitempty"`     // target channels (SocialPost)
	Project  string `json:"project,omitempty"`      // brand/site sub-scope (billing + tenancy axis)
	Voice    string `json:"voice,omitempty"`        // brand-voice guidance for the copy director
	Tone     string `json:"tone,omitempty"`         // tone override for a single draft
	Kind     string `json:"kind,omitempty"`         // asset kind: ecom|product|lifestyle|hover|hero
	Source   string `json:"source_media,omitempty"` // asset source image (design CAD/photo)
	Model    string `json:"model,omitempty"`        // optional zen model override (copy)
}

GenerateInput is the request to draft a piece of content. DocType selects the target marketing type; the rest is generation context. It is the wire body of POST /v1/content/generate and the input of the content_generate automation action.

type GenerateResult

type GenerateResult struct {
	DocType string `json:"doctype"` // the marketing type the draft was filed as
	Name    string `json:"name"`    // the new document's name — its address for every later call
	Status  string `json:"status"`  // always "draft"; the lifecycle owns the initial state
}

GenerateResult is the created draft's identity.

func Generate

func Generate(ctx context.Context, org string, in GenerateInput) (GenerateResult, error)

Generate drafts a piece of content into the CMS and returns its identity. org MUST be the caller's validated tenant (resolved once via principal.Org). It is the ONE generate path — the HTTP handler and the content_generate automation action both call it — so there is a single validated, metered, hook-running write for generated content.

type Generator

type Generator interface {
	Draft(ctx context.Context, org string, in GenerateInput) (map[string]any, error)
}

Generator drafts content field data for a marketing DocType. Implementations are the ONLY place a model/provider is chosen; everything else in the loop is provider- agnostic. A Generator returns the field data map for the draft (copy, media refs, derived fields); Generate stamps status=draft and persists it.

type MediaRef

type MediaRef struct {
	URL  string `json:"url"`
	Alt  string `json:"alt,omitempty"`
	Mime string `json:"mime,omitempty"`
}

MediaRef is one media attachment on a post.

type PublishInput

type PublishInput struct {
	DocType    string `json:"doctype"`
	Name       string `json:"name"`
	ScheduleAt string `json:"scheduleAt,omitempty"` // "" = now
}

PublishInput identifies the CMS item to distribute. The item's channels/caption/media are read from the document, so callers name the item, not its content.

type PublishResult

type PublishResult struct {
	Status      string            `json:"status"`
	Channels    []string          `json:"channels,omitempty"`
	ExternalIDs map[string]string `json:"externalIds,omitempty"`
	Results     []ChannelResult   `json:"results,omitempty"`
}

PublishResult reports the distribution outcome. Status is "distributed" | "scheduled" | "failed" | "not_configured" so a caller (and a transition response) sees the honest state without an error being fatal. "failed" means EVERY targeted channel failed (the whole fan-out missed) — a partial success stays "distributed"/ "scheduled" with the per-channel truth in Results. Results is the per-channel breakdown (which channel went out, which did not, and why).

func Publish

func Publish(ctx context.Context, org string, in PublishInput) (PublishResult, error)

Publish distributes a CMS content item to its channels and records the returned post ids back onto the document (best effort — a distribution outage never wedges the CMS). It is the ONE publish path: the HTTP handler, the content_publish automation action, and a transition's side effect all call it. org MUST be the validated tenant.

type Storefront

type Storefront interface {
	Publish(ctx context.Context, org string, req StorefrontRequest) (StorefrontResult, error)
	// ProductExists reports whether the org's catalog holds a product with this handle
	// (slug). errNotConfigured when the commerce edge is not wired (no token / no
	// reachable commerce) — the integrity gate then SKIPS validation (local dev). A clean
	// (false, nil) is an authoritative "resolved, no such product" the gate fails closed on.
	ProductExists(ctx context.Context, org, handle string) (bool, error)
}

Storefront is the catalog edge — the ONLY place Hanzo Commerce is touched. Publish materializes a published product asset's image into the org's storefront (the product Listing's headerImage); ProductExists is the cheap read the integrity gate uses to reject a content doc that names a product the org's catalog does not have.

type StorefrontRequest

type StorefrontRequest struct {
	Design   string
	Kind     string
	Role     string
	ImageURL string
	Caption  string
}

StorefrontRequest is the provider-agnostic "this asset is the product image" the edge acts on. Design is the product slug (the Listing key); ImageURL is the absolute S3 URL of the asset file; Kind/Role/Caption are art-direction context.

type StorefrontResult

type StorefrontResult struct {
	Status   string `json:"status"`
	Slug     string `json:"slug,omitempty"`
	Store    string `json:"store,omitempty"`
	ImageURL string `json:"imageUrl,omitempty"`
}

StorefrontResult is the honest outcome recorded on a transition. Status is "published" (the product image was set) | "not_configured" (no commerce edge, a fail-closed no-op) | "failed" (the edge was reachable but errored) — never fatal.

func StorefrontPublish

func StorefrontPublish(ctx context.Context, org, doctype, name string) *StorefrontResult

StorefrontPublish is the ONE catalog-publish path — the side effect the `published` transition of an Asset fires. It returns nil when the item is NOT product imagery (not an Asset, no design, or a non-catalog kind) so a transition attaches a storefront result ONLY when it is meaningful. A configured/edge error is recorded on the result (not returned) — like distribution, it is best-effort and never rolls back the status change. org MUST be the validated tenant.

type TransitionResult

type TransitionResult struct {
	DocType      string            `json:"doctype"`
	Name         string            `json:"name"`
	From         string            `json:"from"`
	To           string            `json:"to"`
	Distribution *PublishResult    `json:"distribution,omitempty"`
	Storefront   *StorefrontResult `json:"storefront,omitempty"`
}

TransitionResult is the outcome of a lifecycle move, including any distribution the transition triggered (nil when the target state does not distribute).

func Transition

func Transition(ctx context.Context, org, doctype, name, to, scheduleAt string) (TransitionResult, error)

Transition moves a content item to a new lifecycle state and, when that state distributes (queued/published), fans it out to channels. The status write goes through framework.UpdateData, so the before_save lifecycle hook re-validates the edge at the storage boundary — defence in depth around the CanTransition check here. The distribution side effect is best effort: it records its honest state on the result but NEVER rolls back the status change or raises a 5xx. org MUST be the validated tenant. It is the ONE transition path — the HTTP handler and the content_transition automation action both call it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL