Compare commits

...
Author SHA1 Message Date
hanzo-dev 37022f34ae fix(marketing): additive ALTER so old prod campaigns tables gain scheduled_at
POST/GET /v1/marketing/campaigns 500'd on prod with "table
marketing_campaigns has no column named scheduled_at": migrateCampaigns()
uses CREATE TABLE IF NOT EXISTS, which NEVER alters an existing table, so a
prod DB created before scheduled_at was added to the DDL was frozen at its
original schema and every campaign write (INSERT/UPDATE name it) 500'd. The
store is an encrypted single-file SQLite only the binary can open, so a
hand-patch is impossible — the upgrade MUST happen in migrate-on-open.

- migrateCampaigns: after the CREATE, run an idempotent additive column
  upgrade — ALTER TABLE marketing_campaigns ADD COLUMN scheduled_at
  INTEGER NOT NULL DEFAULT 0 — swallowing SQLite's "duplicate column name"
  (the only error) so it's a no-op on a fresh DB.
- addColumn helper mirrors clients/social/store.go exactly (the ONE way we
  do additive migrations), keyed by an extensible {table,col,def} list.

Tests (store_migrate_test.go, CGO_ENABLED=0 pure-Go cek): a from-OLD-schema DB
(marketing_campaigns without scheduled_at + a legacy row) opens, migrate ADDs
the column, a scheduled_at write succeeds, and the legacy row survives with
scheduled_at defaulted to 0; plus a fresh-DB idempotency re-open. Without the
ALTER the old-schema test reproduces the exact prod 500.

Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:32:13 -07:00
hanzo-dev 75dae858b3 fix(commerce): /v1/store/current reads the org's OWN store + lazily provisions it
getCurrent used datastore.New (the shared system DB) while stores are written
org-namespaced (datastore.NewNamespaced), so GET /v1/store/current ALWAYS
returned the phantom {"store":{"id":"default"}}. The content storefront edge
(clients/content/storefront.go currentStore) treats "default" as
errNotConfigured and SKIPPED publishing every org's product Listing.headerImage
— the storefront edge was dark for every org (gates the karma storefront).

- api/store/current.go: resolve the caller org from context and query its OWN
  namespaced store (the SAME pattern listing.go orgNamespacedDB uses); fall back
  to the minimal default only when no org is in context.
- api/store/handlers.go: /current now runs behind the base auth gate (args =
  tokenRequired) so the org is resolved — custom sub-routes do NOT inherit the
  base CRUD middleware (util/rest.Route), which is why /current was unscoped.
- models/store.EnsureDefault: the ONE canonical, idempotent, org-scoped store
  provisioning primitive (keyed by the stable "default" slug, NO payment creds).
  First authenticated /v1/store/current lazily provisions it — replacing the
  long-dead commented-out store creation in util/provision.

Tests (current_test.go): an org-scoped store round-trips through the real
getCurrent handler (real id, not "default"), is idempotent across calls,
isolates orgs, and degrades to default with no org. store api + perorg green;
content storefront tests green (CGO_ENABLED=0 pure-Go dev build).

Claude-Session: https://claude.ai/code/session_01Gq8suw7uuodAMPDRpo6iAB
2026-07-14 17:27:50 -07:00
7 changed files with 380 additions and 43 deletions
+46 -22
View File
@@ -10,30 +10,54 @@ import (
"github.com/hanzoai/cloud/clients/commerce/models/store"
)
// getCurrent returns the first (default) store for the authenticated org.
// The admin dashboard calls GET /store/current to resolve the active store context.
// getCurrent returns the authenticated org's default store. It resolves the store
// from the org's OWN namespaced datastore (NOT the shared system DB) and lazily
// provisions it on first use. The admin dashboard and the content storefront edge
// (clients/content/storefront.go currentStore) both call GET /store/current to
// resolve the active store id; reading the shared system DB always returned the
// phantom "default", which the storefront edge treats as unconfigured — so it
// skipped publishing product imagery for every org.
func getCurrent(c *gin.Context) {
ctx := middleware.GetContext(c)
db := datastore.New(ctx)
var s store.Store
s.Init(db)
q := s.Query().All().Limit(1)
var stores []store.Store
if _, err := q.GetAll(&stores); err != nil || len(stores) == 0 {
// Return a minimal default so the dashboard can render
c.JSON(http.StatusOK, gin.H{
"store": gin.H{
"id": "default",
"name": "Default Store",
"default_currency": "usd",
"currencies": []string{"usd"},
},
})
org, ok := middleware.GetOrganizationOK(c)
if !ok {
// No authenticated org in context: return a minimal default so the dashboard
// still renders, exactly as before. The route now runs behind tokenRequired,
// so real callers (dashboard IAM, storefront service token) always have an org.
c.JSON(http.StatusOK, defaultStorePayload())
return
}
c.JSON(http.StatusOK, gin.H{"store": stores[0]})
// Resolve the caller org's OWN store — the SAME per-org namespace the write path
// (listing.go orgNamespacedDB, rest.newEntity) persists into.
db := datastore.NewNamespaced(org.Namespaced(c))
// Return the org's existing store if it already has one (any slug).
var stores []store.Store
if _, err := store.New(db).Query().All().Limit(1).GetAll(&stores); err == nil && len(stores) > 0 {
c.JSON(http.StatusOK, gin.H{"store": stores[0]})
return
}
// First authenticated visit for an org with no store yet: lazily provision the
// canonical default store (idempotent, org-scoped, no payment creds) so the
// storefront edge resolves a REAL store id instead of the phantom "default".
s, err := store.EnsureDefault(db)
if err != nil {
c.JSON(http.StatusOK, defaultStorePayload())
return
}
c.JSON(http.StatusOK, gin.H{"store": s})
}
// defaultStorePayload is the minimal fallback used only when no org context is
// present or provisioning fails — never the normal path for an authenticated org.
func defaultStorePayload() gin.H {
return gin.H{
"store": gin.H{
"id": "default",
"name": "Default Store",
"default_currency": "usd",
"currencies": []string{"usd"},
},
}
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright © 2026 Hanzo AI. MIT License.
package store
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/hanzoai/cloud/clients/commerce/datastore"
"github.com/hanzoai/cloud/clients/commerce/datastore/query"
"github.com/hanzoai/cloud/clients/commerce/db"
"github.com/hanzoai/cloud/clients/commerce/models/organization"
commercestore "github.com/hanzoai/cloud/clients/commerce/models/store"
)
// withResolver installs the SAME per-org DB resolver the production commerce
// bootstrap installs (commerce.go: datastore.SetOrgDBResolver(app.DB.Org)), so
// datastore.NewNamespaced routes each org to its OWN physical store — the wiring
// the getCurrent fix depends on. Mirrors clients/commerce/test/perorg.
func withResolver(t *testing.T) func() {
t.Helper()
dir, err := os.MkdirTemp("", "storecurrent-*")
if err != nil {
t.Fatal(err)
}
cfg := db.DefaultConfig()
cfg.DataDir = dir
cfg.EnableVectorSearch = false
cfg.EnableDatastore = false
mgr, err := db.NewManager(cfg)
if err != nil {
os.RemoveAll(dir)
t.Fatalf("NewManager: %v", err)
}
sys, err := mgr.Org("system")
if err != nil {
mgr.Close()
os.RemoveAll(dir)
t.Fatalf("Org(system): %v", err)
}
datastore.SetDefaultDB(sys)
query.SetDefaultDB(sys)
datastore.SetOrgDBResolver(mgr.Org)
return func() {
datastore.SetOrgDBResolver(nil)
datastore.SetDefaultDB(nil)
query.SetDefaultDB(nil)
mgr.Close()
os.RemoveAll(dir)
}
}
type storeResp struct {
Store struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
} `json:"store"`
}
// callCurrent drives the real getCurrent handler with the given org bound in
// context exactly as middleware.TokenRequired does (c.Set("organization", …)),
// and returns the decoded response. An empty org omits the binding, exercising
// the no-identity fallback.
func callCurrent(t *testing.T, org string) storeResp {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/store/current", nil)
if org != "" {
c.Set("organization", &organization.Organization{Name: org})
}
getCurrent(c)
if w.Code != http.StatusOK {
t.Fatalf("org %q: getCurrent status = %d, want 200", org, w.Code)
}
var resp storeResp
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("org %q: decode body %q: %v", org, w.Body.String(), err)
}
return resp
}
// TestGetCurrentProvisionsOrgScopedStore is the round-trip proof for the fix: an
// authenticated org's GET /v1/store/current resolves (and lazily provisions) a
// REAL org-scoped store id — never the phantom shared "default" — the store id
// the content storefront edge needs to publish product imagery.
func TestGetCurrentProvisionsOrgScopedStore(t *testing.T) {
defer withResolver(t)()
// First visit for an org with no store yet → a real store id is provisioned.
karma := callCurrent(t, "karma")
if karma.Store.ID == "" || karma.Store.ID == "default" {
t.Fatalf("karma store id = %q, want a real provisioned id (not empty/\"default\")", karma.Store.ID)
}
if karma.Store.Slug != commercestore.DefaultSlug {
t.Fatalf("karma store slug = %q, want %q", karma.Store.Slug, commercestore.DefaultSlug)
}
// Idempotent: a second visit returns the SAME store, not a duplicate.
karma2 := callCurrent(t, "karma")
if karma2.Store.ID != karma.Store.ID {
t.Fatalf("second GET /store/current provisioned a new store: %q != %q", karma2.Store.ID, karma.Store.ID)
}
// Org isolation: a different org resolves its OWN store, never karma's.
other := callCurrent(t, "acme")
if other.Store.ID == "" || other.Store.ID == "default" {
t.Fatalf("acme store id = %q, want a real provisioned id", other.Store.ID)
}
if other.Store.ID == karma.Store.ID {
t.Fatalf("CROSS-TENANT LEAK: acme resolved karma's store id %q", karma.Store.ID)
}
}
// TestGetCurrentNoOrgFallsBackToDefault proves the endpoint degrades cleanly (no
// panic, no 500) when reached with no org in context — returning the minimal
// default payload rather than provisioning against a missing tenant.
func TestGetCurrentNoOrgFallsBackToDefault(t *testing.T) {
defer withResolver(t)()
resp := callCurrent(t, "")
if resp.Store.ID != "default" {
t.Fatalf("no-org store id = %q, want \"default\"", resp.Store.ID)
}
}
// TestEnsureDefaultIsIdempotent proves the canonical provisioning primitive is
// idempotent at the datastore layer: repeated calls in one org's namespace return
// the SAME store, and a second org gets a distinct one.
func TestEnsureDefaultIsIdempotent(t *testing.T) {
defer withResolver(t)()
ctxA := (&organization.Organization{Name: "org-a"}).Namespaced(context.Background())
a1, err := commercestore.EnsureDefault(datastore.NewNamespaced(ctxA))
if err != nil {
t.Fatalf("EnsureDefault org-a: %v", err)
}
a2, err := commercestore.EnsureDefault(datastore.NewNamespaced(ctxA))
if err != nil {
t.Fatalf("EnsureDefault org-a (2): %v", err)
}
if a1.Id() == "" || a1.Id() != a2.Id() {
t.Fatalf("EnsureDefault not idempotent: %q vs %q", a1.Id(), a2.Id())
}
ctxB := (&organization.Organization{Name: "org-b"}).Namespaced(context.Background())
b1, err := commercestore.EnsureDefault(datastore.NewNamespaced(ctxB))
if err != nil {
t.Fatalf("EnsureDefault org-b: %v", err)
}
if b1.Id() == a1.Id() {
t.Fatalf("CROSS-TENANT: org-b store id == org-a store id (%q)", a1.Id())
}
}
+6 -2
View File
@@ -17,8 +17,12 @@ func Route(router router.Router, args ...gin.HandlerFunc) {
api := rest.New(store.Store{})
// Admin dashboard expects /store/current to return the default store.
api.GET("/current", getCurrent)
// Admin dashboard + the content storefront edge expect /store/current to return
// the caller org's store. getCurrent resolves the org FROM CONTEXT, so /current
// must run behind the same base auth gate (args = tokenRequired) that sets it —
// custom sub-routes do NOT inherit the base CRUD middleware (see util/rest.Route).
current := append(append([]gin.HandlerFunc{}, args...), getCurrent)
api.GET("/current", current...)
// Mint the org's least-privilege Published storefront read key (design
// path b). Admin-gated + org-bound; the returned token is stored in KMS and
+22
View File
@@ -213,6 +213,28 @@ func New(db *datastore.Datastore) *Store {
return s
}
// DefaultSlug is the stable slug of the store every org gets on first use. It is
// safe to reuse across tenants because each org's merchant rows live in its OWN
// namespaced store (datastore.NewNamespaced), so the slug never collides.
const DefaultSlug = "default"
// EnsureDefault returns the org's default store, creating it on first use. This is
// the ONE canonical way an org gets its commerce store: idempotent (keyed by the
// stable DefaultSlug), org-scoped (db MUST be a per-org datastore.NewNamespaced),
// and carrying NO payment credentials — binding Square/Stripe stays a separate
// business step. It replaces the long-dead commented-out provisioning in
// util/provision: a store is lazily provisioned at the first authenticated
// GET /v1/store/current instead of eagerly at org creation.
func EnsureDefault(db *datastore.Datastore) (*Store, error) {
s := New(db)
s.Name = "Default Store"
s.Slug = DefaultSlug
if err := s.GetOrCreate("Slug=", DefaultSlug); err != nil {
return nil, err
}
return s, nil
}
func Query(db *datastore.Datastore) datastore.Query {
return db.Query("store")
}
+4 -19
View File
@@ -17,23 +17,8 @@ func Provision(org *organization.Organization, usr *user.User) {
usr.MustCreate()
}
// // Figure out ownership
// if usr.Organizations == nil {
// org.Owners
// }
// // Create default store
// stor := store.New(nsdb)
// stor.Name = "development"
// stor.GetOrCreate("Name=", stor.Name)
// stor.MustSetKey("KawdtZuoMY")
// stor.Prefix = "/"
// stor.Currency = currency.USD
// stor.Mailchimp.APIKey = ""
// stor.Mailchimp.ListId = "421751eb03"
// stor.MustUpdate()
// org.AddDefaultTokens()
// org.MustUpdate()
// The org's commerce store is NOT provisioned here: it is created lazily and
// idempotently on the org's first authenticated GET /v1/store/current, via the
// ONE canonical primitive store.EnsureDefault (org-scoped, no payment creds).
// See clients/commerce/api/store/current.go.
}
+26
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
// cek opens the store encrypted at rest (migrate-on-open + shred).
"github.com/hanzoai/cloud/cek"
@@ -99,9 +100,34 @@ CREATE INDEX IF NOT EXISTS ix_marketing_campaigns_org_channel ON marketing_campa
if _, err := s.db.Exec(ddl); err != nil {
return fmt.Errorf("marketing migrate campaigns: %w", err)
}
// Idempotent column upgrades for a pre-existing prod table created before a
// column was added to the DDL above. CREATE TABLE IF NOT EXISTS never alters an
// existing table, so an old marketing_campaigns is frozen at its original schema
// and every write of a newer column 500s ("table marketing_campaigns has no
// column named scheduled_at"). ADD COLUMN on a column that already exists is the
// only error swallowed (see addColumn). The store is an encrypted single-file
// SQLite only the binary can open, so this migrate-on-open is the ONLY way an old
// prod table gains the column.
for _, ac := range []struct{ table, col, def string }{
{"marketing_campaigns", "scheduled_at", "INTEGER NOT NULL DEFAULT 0"},
} {
if err := s.addColumn(ac.table, ac.col, ac.def); err != nil {
return err
}
}
return nil
}
// addColumn adds a column to an existing table, treating an already-present column
// as success (SQLite has no ADD COLUMN IF NOT EXISTS). Mirrors clients/social/store.go.
func (s *Store) addColumn(table, col, def string) error {
_, err := s.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def))
if err == nil || strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("marketing migrate: add %s.%s: %w", table, col, err)
}
// Close closes the underlying database. Idempotent-safe via sql.DB.
func (s *Store) Close() error { return s.db.Close() }
+111
View File
@@ -0,0 +1,111 @@
// Copyright © 2026 Hanzo AI. MIT License.
package marketing
import (
"context"
"path/filepath"
"testing"
"github.com/hanzoai/cloud/cek"
)
// TestMigrateUpgradesOldCampaignsTable is the regression for the prod 500
// "table marketing_campaigns has no column named scheduled_at": a marketing_campaigns
// table created BEFORE scheduled_at was added to the DDL must gain the column on the
// next store open. CREATE TABLE IF NOT EXISTS never alters an existing table, so
// without the additive ALTER an old prod table is frozen at its original schema and
// every campaign write 500s. The store is an encrypted single-file SQLite only the
// binary can open, so migrate-on-open is the ONLY upgrade path (no hand-patch).
func TestMigrateUpgradesOldCampaignsTable(t *testing.T) {
path := filepath.Join(t.TempDir(), "marketing.db")
// Seed a prod-shaped OLD DB: marketing_campaigns WITHOUT scheduled_at, plus an
// existing row — exactly what a pre-scheduling prod deployment holds. Written
// through cek so the on-disk format matches what openStore reads back.
raw, err := cek.Open(path)
if err != nil {
t.Fatalf("cek.Open (seed old db): %v", err)
}
if _, err := raw.Exec(`CREATE TABLE marketing_campaigns (
id TEXT PRIMARY KEY,
org TEXT NOT NULL,
name TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'email',
status TEXT NOT NULL DEFAULT 'draft',
objective TEXT NOT NULL DEFAULT '',
budget INTEGER NOT NULL DEFAULT 0,
spend INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);`); err != nil {
t.Fatalf("create old-schema table: %v", err)
}
if _, err := raw.Exec(`INSERT INTO marketing_campaigns
(id,org,name,channel,status,objective,budget,spend,created_at,updated_at)
VALUES ('old1','karma','Legacy','email','active','',0,0,1,2)`); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
_ = raw.Close()
// Open through the real store: migrate() must ADD the scheduled_at column to the
// existing table (idempotent — swallows "duplicate column name" on a fresh DB).
s, err := openStore(path)
if err != nil {
t.Fatalf("openStore (migrate must upgrade old table): %v", err)
}
t.Cleanup(func() { _ = s.Close() })
ctx := context.Background()
// The exact prod repro: a create that writes scheduled_at must now SUCCEED, not
// 500 with "no column named scheduled_at".
if _, err := s.CreateCampaign(ctx, Campaign{
ID: "new1", Org: "karma", Name: "Spring", Channel: "email",
Status: "draft", ScheduledAt: 123, CreatedAt: 10, UpdatedAt: 10,
}); err != nil {
t.Fatalf("CreateCampaign after migrate must not 500 on scheduled_at: %v", err)
}
got, err := s.GetCampaign(ctx, "karma", "new1")
if err != nil {
t.Fatalf("GetCampaign new: %v", err)
}
if got.ScheduledAt != 123 {
t.Fatalf("scheduled_at = %d, want 123", got.ScheduledAt)
}
// The legacy row survived the upgrade and defaults scheduled_at to 0.
old, err := s.GetCampaign(ctx, "karma", "old1")
if err != nil {
t.Fatalf("GetCampaign legacy: %v", err)
}
if old.ScheduledAt != 0 || old.Name != "Legacy" {
t.Fatalf("legacy row corrupted after migrate: %+v", old)
}
}
// TestMigrateOnFreshDBIsIdempotent proves the additive ALTER is a no-op on a fresh
// DB (the column already exists via the CREATE) and that re-opening never errors.
func TestMigrateOnFreshDBIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "marketing.db")
s, err := openStore(path)
if err != nil {
t.Fatalf("openStore fresh: %v", err)
}
_ = s.Close()
// Re-open: migrate runs again; the ALTER must swallow "duplicate column name".
s2, err := openStore(path)
if err != nil {
t.Fatalf("re-open must be idempotent: %v", err)
}
t.Cleanup(func() { _ = s2.Close() })
if _, err := s2.CreateCampaign(context.Background(), Campaign{
ID: "c1", Org: "karma", Name: "x", Channel: "email", Status: "draft",
ScheduledAt: 7, CreatedAt: 1, UpdatedAt: 1,
}); err != nil {
t.Fatalf("CreateCampaign on fresh+reopened db: %v", err)
}
}