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
- func CanTransition(from, to string) bool
- func DocTypes() []framework.DocType
- func IsLive(s string) bool
- func IsStatus(s string) bool
- func Mount(app cloud.Router, deps cloud.Deps) error
- func Shutdown() error
- func Transitions(from string) []string
- type CatalogAssetResult
- type Channel
- type ChannelResult
- type DistributeRequest
- type DistributeResult
- type Distributor
- type GenerateInput
- type GenerateResult
- type Generator
- type MediaRef
- type PublishInput
- type PublishResult
- type Storefront
- type StorefrontRequest
- type StorefrontResult
- type TransitionResult
Constants ¶
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.
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)
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.
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.
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 ¶
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 ¶
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 ¶
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 Mount ¶
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 ¶
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.