Documentation
¶
Overview ¶
Package social mounts the Hanzo Cloud /v1/social/* surface: a native-Go, per-org social-media store on Base/SQLite. It is the in-process fold of the live social stack (github.com/hanzoai/social — the social-backend / social-frontend / social-orchestrator pods, a Postiz-style scheduler) onto the ONE cloud framework (zip/Fiber + cloud.Deps + per-org SQLite) — the same shape every other in-repo subsystem uses (clients/crm is the twin, clients/marketing the sibling fold), NOT a proxy to the standalone social pods.
Two entities, faithful to the live stack's Public API (see clients/content publish.go, which already talks to it): an Account is a connected channel (the stack's "integration": GET /public/v1/integrations), and a Post is content published or scheduled to a channel (POST /public/v1/posts {type:now|schedule, date, …}). Scheduling is not a third entity — it is a Post with Status=="scheduled" carrying a future ScheduleAt.
The publish edge (publish.go) and the scheduler (scheduler.go) ARE folded: a post fans out to its channel's connected accounts through the Publisher seam, on an explicit publish, on create (when scheduled for now-or-earlier), and on the scheduler tick (scheduled → published when the time arrives). The provider push itself is the swappable Publisher edge; its fail-closed default is honest — no Hanzo deployment carries the provider OAuth-app credentials (providerCreds) the live orchestrator needs, so a publish reports exactly which credentials are missing (503) and NEVER fakes success. The per-account OAuth connect flow + native per-provider push are the honest remaining gap (see the fold report + GET /v1/social/providers).
Tenant isolation is enforced SERVER-SIDE on every request: the org is principal.Org(c) — the value SanitizeIdentity minted from the VALIDATED bearer owner claim (HIP-0026) — and NEVER a client-supplied header. Every store query filters WHERE org=?, so one tenant can never read or mutate another's data.
Surface (all org-scoped; /v1 only):
GET /v1/social/summary per-org roll-up (posts/scheduled/published/accounts)
GET /v1/social/providers publish-readiness per network (+ missing creds)
GET /v1/social/accounts list accounts (?provider=) -> {data:[…]}
POST /v1/social/accounts connect an account -> Account (201)
GET /v1/social/accounts/:id account detail -> Account
PUT /v1/social/accounts/:id update an account -> Account
DELETE /v1/social/accounts/:id disconnect an account
GET /v1/social/posts list posts (?status=) -> {data:[…]}
POST /v1/social/posts create/schedule a post -> Post (201)
GET /v1/social/posts/:id post detail -> Post
PUT /v1/social/posts/:id update a post -> Post
DELETE /v1/social/posts/:id delete a post
POST /v1/social/posts/:id/publish publish a post now -> Post
serve.go auto-registers GET /v1/social/health (this subsystem does not set OwnsHealth, so the generic always-ok liveness route serves it).
Index ¶
- func Mount(app cloud.Router, deps cloud.Deps) error
- func Shutdown() error
- type Account
- type OrgPost
- type Post
- type ProviderCapability
- type Publisher
- type Store
- func (s *Store) ClaimForPublish(ctx context.Context, org, id string, now int64) (Post, bool, error)
- func (s *Store) Close() error
- func (s *Store) Counts(ctx context.Context, org string) (posts, scheduled, published, accounts int, err error)
- func (s *Store) CreateAccount(ctx context.Context, a Account) (Account, error)
- func (s *Store) CreatePost(ctx context.Context, p Post) (Post, error)
- func (s *Store) DeleteAccount(ctx context.Context, org, id string) (bool, error)
- func (s *Store) DeletePost(ctx context.Context, org, id string) (bool, error)
- func (s *Store) DueScheduled(ctx context.Context, now int64, limit int) ([]OrgPost, error)
- func (s *Store) GetAccount(ctx context.Context, org, id string) (Account, error)
- func (s *Store) GetPost(ctx context.Context, org, id string) (Post, error)
- func (s *Store) ListAccounts(ctx context.Context, org, provider string, limit int) ([]Account, error)
- func (s *Store) ListConnectedAccounts(ctx context.Context, org, provider string) ([]Account, error)
- func (s *Store) ListPosts(ctx context.Context, org, status string, limit int) ([]Post, error)
- func (s *Store) MarkFailed(ctx context.Context, org, id, reason string, now int64) error
- func (s *Store) MarkPublished(ctx context.Context, org, id, accountID, externalID string, now int64) error
- func (s *Store) RecoverStuckPublishing(ctx context.Context, now int64) (int64, error)
- func (s *Store) UpdateAccount(ctx context.Context, a Account) (Account, error)
- func (s *Store) UpdatePost(ctx context.Context, p Post) (Post, error)
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Account ¶
type Account struct {
ID string `json:"id"`
Org string `json:"-"`
Provider string `json:"provider"`
Handle string `json:"handle"`
Status string `json:"status"`
// Token is the account's provider access token (written by the connect/OAuth
// flow, which has NOT landed yet). It is NEVER serialized to an API response
// (`json:"-"`), so it cannot leak to the browser; only the publisher reads it.
// NOT yet encrypted at rest: the store opens via cek.Open, which today runs the
// no-key PLAINTEXT fallback — real column encryption (WithRawKey sourced from KMS)
// lands together with the connect flow that first writes a token. The column is
// empty today, so no plaintext secret ships. (The live Postiz stack stores this
// token in PLAINTEXT in Postgres.)
Token string `json:"-"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Account is an org-scoped connected social account (a Postiz-style "integration" on the live social stack). Provider is the network (x/facebook/instagram/…); Status is the connection lifecycle (connected/disconnected/error) — both validated at the write layer against the fixed vocabularies in social.go.
type OrgPost ¶
OrgPost is the minimal (org,id) identity the scheduler's due sweep returns — enough to dispatch each due post back into the strictly org-scoped publish path.
type Post ¶
type Post struct {
ID string `json:"id"`
Org string `json:"-"`
Content string `json:"content"`
Channel string `json:"channel"`
Status string `json:"status"`
ScheduleAt int64 `json:"scheduleAt"`
// Media is the post's attached media as a list of URLs (images today; the
// composer's URL field now, an S3 picker later, populate it). Stored as a JSON
// array in the media TEXT column and ALWAYS serialized as an array (never null),
// so a client can rely on `media` being present. Bounded at the write layer
// (normMedia in social.go): each URL clipped to maxField, the list to maxMedia.
Media []string `json:"media"`
// AccountID / ExternalID / Error are server-managed publish results, set only by
// the publish path (never by a client update): the account a post was published
// through, the provider's returned external post id (for reconciliation), and the
// last failure reason. Empty until a publish attempt lands.
AccountID string `json:"accountId,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
Post is an org-scoped social post. Channel is the target network (x/facebook/instagram/…); Status is the lifecycle (draft/scheduled/published/ failed). ScheduleAt is a unix timestamp (0 = not scheduled / publish now) — a post with Status=="scheduled" carries the future ScheduleAt. All validated at the write layer against the fixed vocabularies in social.go.
type ProviderCapability ¶
type ProviderCapability struct {
Provider string `json:"provider"`
CredentialsConfigured bool `json:"credentialsConfigured"`
MissingCredentials []string `json:"missingCredentials,omitempty"`
}
ProviderCapability is one row of the capabilities read (GET /v1/social/providers): a provider and whether this deployment has its OAuth-app credentials, plus the exact env vars still missing. It is the console's honest connect affordance and the coordinator's checklist of what to supply before cutover.
type Publisher ¶
type Publisher interface {
Publish(ctx context.Context, org string, acct Account, post Post) (externalID string, err error)
}
Publisher is the provider edge: it sends ONE post through ONE connected account and returns the provider's external post id. Implementations are the ONLY place a social provider API is touched. Injectable so the publish machine is testable without a network and so the coordinator can swap the real edge in at Mount without touching the machine.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the social database. ONE SQLite file ({DataDir}/social.db) holds every org's records; tenant isolation is the `org` column, enforced on EVERY query. This mirrors clients/crm exactly (the ONE storage pattern). MaxOpenConns(1) serializes writes against the single-writer file.
func (*Store) ClaimForPublish ¶
ClaimForPublish atomically claims a post for a publish attempt: it flips a publishable post (draft/scheduled/failed) to the transient 'publishing' state and returns it. This is the concurrency guard shared by the HTTP publish handler and the scheduler — with SQLite's single writer, exactly one caller's UPDATE affects a given row, so a post is published AT MOST once even if the handler and a scheduler tick race for it. Returns (post, true) when THIS caller won the claim, (post, false) when the post is not claimable (already published, or being published by another caller), and errNotFound if the post is not the org's.
func (*Store) Counts ¶
func (s *Store) Counts(ctx context.Context, org string) (posts, scheduled, published, accounts int, err error)
Counts returns the per-org roll-up: total posts, how many are scheduled, how many are published, and the number of connected accounts — a real, non-fabricated summary for the social module's overview cards.
func (*Store) CreateAccount ¶
func (*Store) DeleteAccount ¶
func (*Store) DeletePost ¶
func (*Store) DueScheduled ¶
DueScheduled returns up to `limit` posts whose schedule time has arrived (status='scheduled' AND schedule_at <= now), oldest-due first. This is the SINGLE system-level (cross-org) read in the subsystem — the scheduler's due sweep. It reads only the identifiers needed to dispatch; the actual publish re-enters the org-scoped path (publishPost), so tenant isolation is preserved: the sweep dispatches, it never returns or mutates one org's content under another's request. A 'scheduled' post with an unset time (schedule_at=0) is due immediately — the durable backstop for the on-create fanout.
func (*Store) GetAccount ¶
func (*Store) ListAccounts ¶
func (s *Store) ListAccounts(ctx context.Context, org, provider string, limit int) ([]Account, error)
ListAccounts lists the org's accounts, optionally filtered by provider (provider=="" means all). Most-recently-updated first.
func (*Store) ListConnectedAccounts ¶
ListConnectedAccounts returns an org's CONNECTED accounts for one provider — the publish targets for a post on that channel. status='connected' only (a disconnected or errored account is never a publish target). Org-scoped like every other read.
func (*Store) ListPosts ¶
ListPosts lists the org's posts, optionally filtered by status (status=="" means all). Most-recently-updated first.
func (*Store) MarkFailed ¶
MarkFailed records a failed publish: status→failed with an honest reason (retryable — a failed post can be claimed again). Org-scoped.
func (*Store) MarkPublished ¶
func (s *Store) MarkPublished(ctx context.Context, org, id, accountID, externalID string, now int64) error
MarkPublished records a successful publish: status→published, the account it went through, the provider's external post id, and clears any prior error. Org-scoped.
func (*Store) RecoverStuckPublishing ¶
RecoverStuckPublishing resets any post left in the transient 'publishing' state by a crash mid-attempt back to 'failed' (retryable). It is deliberately NOT reset to 'published': we cannot know whether the provider actually received the post, and a false 'published' would silently drop it, whereas a false 'failed' is a safe, visible retry. Runs once at Mount; cross-org recovery sweep; returns the count reset.