social

package
v1.801.307 Latest Latest
Warning

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

Go to latest
Published: Jul 28, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func Mount

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

Mount wires the social surface onto app per HIP-0106. It keeps a package global (mounted) for Shutdown, so it constructs the Service value directly — the same "complex flavour" clients/crm uses.

func Shutdown

func Shutdown() error

Shutdown stops the scheduler and closes the social store, in that order (drain the background publisher before releasing its store). Idempotent.

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

type OrgPost struct {
	Org string
	ID  string
}

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

func (s *Store) ClaimForPublish(ctx context.Context, org, id string, now int64) (Post, bool, error)

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) Close

func (s *Store) Close() error

Close closes the underlying database. Idempotent-safe via sql.DB.

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 (s *Store) CreateAccount(ctx context.Context, a Account) (Account, error)

func (*Store) CreatePost

func (s *Store) CreatePost(ctx context.Context, p Post) (Post, error)

func (*Store) DeleteAccount

func (s *Store) DeleteAccount(ctx context.Context, org, id string) (bool, error)

func (*Store) DeletePost

func (s *Store) DeletePost(ctx context.Context, org, id string) (bool, error)

func (*Store) DueScheduled

func (s *Store) DueScheduled(ctx context.Context, now int64, limit int) ([]OrgPost, error)

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 (s *Store) GetAccount(ctx context.Context, org, id string) (Account, error)

func (*Store) GetPost

func (s *Store) GetPost(ctx context.Context, org, id string) (Post, error)

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

func (s *Store) ListConnectedAccounts(ctx context.Context, org, provider string) ([]Account, error)

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

func (s *Store) ListPosts(ctx context.Context, org, status string, limit int) ([]Post, error)

ListPosts lists the org's posts, optionally filtered by status (status=="" means all). Most-recently-updated first.

func (*Store) MarkFailed

func (s *Store) MarkFailed(ctx context.Context, org, id, reason string, now int64) error

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

func (s *Store) RecoverStuckPublishing(ctx context.Context, now int64) (int64, error)

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.

func (*Store) UpdateAccount

func (s *Store) UpdateAccount(ctx context.Context, a Account) (Account, error)

func (*Store) UpdatePost

func (s *Store) UpdatePost(ctx context.Context, p Post) (Post, error)

Jump to

Keyboard shortcuts

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