feat(api): M4.2 — REST surface + pgx Postgres store + OpenAPI 3.1
Full M4.2 deliverable: 16 endpoints (tenants CRUD + lifecycle, catalog, entitlements, API keys with argon2 hashing, audit append + filter), Store interface with pgx-backed Postgres + in-memory parallel implementations exercised by the same eachStore harness, openapi.yaml at 3.1 with kin-openapi contract test. M4.3 adds auth. Refs: M4.2
This commit was merged in pull request #7.
This commit is contained in:
+296
-30
@@ -1,57 +1,95 @@
|
||||
// Package store is a stand-in for the real Postgres-backed tenant store.
|
||||
// The skeleton ships an in-memory implementation pre-seeded with one tenant
|
||||
// (acme) so portal middleware has something to resolve in local dev.
|
||||
// Replace with a pgx-backed implementation in the M4.1 follow-up PR.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("tenant not found")
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // active | trial | frozen | archived | demo
|
||||
Plan string `json:"plan"` // starter | professional | enterprise
|
||||
Products []string `json:"products"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Memory — in-process Store used when DATABASE_URL is empty. Convenient for
|
||||
// local dev when you don't want to bring up Postgres + run migrations.
|
||||
// Pre-seeded with the acme tenant.
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
bySlug map[string]*Tenant
|
||||
byID map[string]*Tenant
|
||||
mu sync.RWMutex
|
||||
tenants map[string]*Tenant // id → tenant
|
||||
bySlug map[string]string // slug → id
|
||||
products map[string]map[string]*TenantProduct // tenant_id → product → row
|
||||
apiKeys map[string]*apiKeyWithHash // id → key
|
||||
byPrefix map[string]string // prefix → id
|
||||
audit []*AuditEvent
|
||||
auditID int64
|
||||
}
|
||||
|
||||
type apiKeyWithHash struct {
|
||||
APIKey
|
||||
Hash string
|
||||
}
|
||||
|
||||
// NewMemory returns a fresh in-memory store with the seed acme tenant.
|
||||
func NewMemory() *Memory {
|
||||
m := &Memory{
|
||||
bySlug: make(map[string]*Tenant),
|
||||
byID: make(map[string]*Tenant),
|
||||
tenants: make(map[string]*Tenant),
|
||||
bySlug: make(map[string]string),
|
||||
products: make(map[string]map[string]*TenantProduct),
|
||||
apiKeys: make(map[string]*apiKeyWithHash),
|
||||
byPrefix: make(map[string]string),
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
seed := &Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000001",
|
||||
Slug: "acme",
|
||||
Name: "Acme Inc.",
|
||||
Status: "active",
|
||||
Kind: "customer",
|
||||
Plan: "professional",
|
||||
Products: []string{"certifai", "compliance"},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.tenants[seed.ID] = seed
|
||||
m.bySlug[seed.Slug] = seed.ID
|
||||
m.products[seed.ID] = map[string]*TenantProduct{
|
||||
"certifai": {TenantID: seed.ID, Product: "certifai", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now},
|
||||
"compliance": {TenantID: seed.ID, Product: "compliance", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
m.bySlug[seed.Slug] = seed
|
||||
m.byID[seed.ID] = seed
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) {
|
||||
func (m *Memory) Close() {}
|
||||
func (m *Memory) Ping(_ context.Context) error { return nil }
|
||||
|
||||
// ─── tenants ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, taken := m.bySlug[in.Slug]; taken {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
t := &Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Slug: in.Slug,
|
||||
Name: in.Name,
|
||||
Status: "trial",
|
||||
Kind: firstNonEmpty(in.Kind, "customer"),
|
||||
Plan: firstNonEmpty(in.Plan, "starter"),
|
||||
SalesOwner: in.SalesOwner,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
m.tenants[t.ID] = t
|
||||
m.bySlug[t.Slug] = t.ID
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetTenant(_ context.Context, id string) (*Tenant, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.bySlug[slug]
|
||||
t, ok := m.tenants[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -59,13 +97,241 @@ func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) {
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) {
|
||||
func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.byID[id]
|
||||
id, ok := m.bySlug[slug]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
t := m.tenants[id]
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateTenant(_ context.Context, id string, in TenantUpdate) (*Tenant, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
t, ok := m.tenants[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if in.Status != nil {
|
||||
t.Status = *in.Status
|
||||
}
|
||||
if in.Plan != nil {
|
||||
t.Plan = *in.Plan
|
||||
}
|
||||
if in.ErpCustomerID != nil {
|
||||
t.ErpCustomerID = *in.ErpCustomerID
|
||||
}
|
||||
if in.StripeCustID != nil {
|
||||
t.StripeCustID = *in.StripeCustID
|
||||
}
|
||||
if in.TrialEndsAt != nil {
|
||||
t.TrialEndsAt = in.TrialEndsAt
|
||||
}
|
||||
if in.ContractStart != nil {
|
||||
t.ContractStart = in.ContractStart
|
||||
}
|
||||
if in.ContractEnd != nil {
|
||||
t.ContractEnd = in.ContractEnd
|
||||
}
|
||||
if in.SalesOwner != nil {
|
||||
t.SalesOwner = *in.SalesOwner
|
||||
}
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// ─── entitlements ─────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Memory) UpsertTenantProduct(_ context.Context, tp TenantProduct) (*TenantProduct, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tp.TenantID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if _, ok := m.products[tp.TenantID]; !ok {
|
||||
m.products[tp.TenantID] = map[string]*TenantProduct{}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
stored, exists := m.products[tp.TenantID][tp.Product]
|
||||
if !exists {
|
||||
stored = &TenantProduct{
|
||||
TenantID: tp.TenantID,
|
||||
Product: tp.Product,
|
||||
CreatedAt: now,
|
||||
}
|
||||
m.products[tp.TenantID][tp.Product] = stored
|
||||
}
|
||||
stored.Enabled = tp.Enabled
|
||||
stored.Config = tp.Config
|
||||
stored.ExpiresAt = tp.ExpiresAt
|
||||
stored.UpdatedAt = now
|
||||
cp := *stored
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListTenantProducts(_ context.Context, tenantID string) ([]TenantProduct, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
out := make([]TenantProduct, 0, len(m.products[tenantID]))
|
||||
for _, p := range m.products[tenantID] {
|
||||
out = append(out, *p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Product < out[j].Product })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ─── api keys ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Memory) CreateAPIKey(_ context.Context, in APIKeyCreate) (*APIKey, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[in.TenantID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
k := &apiKeyWithHash{
|
||||
APIKey: APIKey{
|
||||
ID: uuid.NewString(),
|
||||
TenantID: in.TenantID,
|
||||
Product: in.Product,
|
||||
Name: in.Name,
|
||||
Scopes: append([]string{}, in.Scopes...),
|
||||
Prefix: in.Prefix,
|
||||
CreatedBy: in.CreatedBy,
|
||||
CreatedAt: now,
|
||||
},
|
||||
Hash: in.Hash,
|
||||
}
|
||||
m.apiKeys[k.ID] = k
|
||||
m.byPrefix[k.Prefix] = k.ID
|
||||
cp := k.APIKey
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) FindAPIKeyByPrefix(_ context.Context, prefix string) (*APIKey, string, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
id, ok := m.byPrefix[prefix]
|
||||
if !ok {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
k := m.apiKeys[id]
|
||||
if k.RevokedAt != nil {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
cp := k.APIKey
|
||||
return &cp, k.Hash, nil
|
||||
}
|
||||
|
||||
func (m *Memory) TouchAPIKeyUsed(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
k, ok := m.apiKeys[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
k.LastUsedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) RevokeAPIKey(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
k, ok := m.apiKeys[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
k.RevokedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListAPIKeys(_ context.Context, tenantID string) ([]APIKey, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
out := []APIKey{}
|
||||
for _, k := range m.apiKeys {
|
||||
if k.TenantID == tenantID {
|
||||
out = append(out, k.APIKey)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ─── audit ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m *Memory) AppendAudit(_ context.Context, ev AuditEvent) (*AuditEvent, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.auditID++
|
||||
ev.ID = m.auditID
|
||||
ev.CreatedAt = time.Now().UTC()
|
||||
cp := ev
|
||||
m.audit = append(m.audit, &cp)
|
||||
out := ev
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListAudit(_ context.Context, f AuditFilter) ([]AuditEvent, int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
matches := []AuditEvent{}
|
||||
for _, ev := range m.audit {
|
||||
if ev.ID <= f.Cursor {
|
||||
continue
|
||||
}
|
||||
if f.TenantID != "" && ev.TenantID != f.TenantID {
|
||||
continue
|
||||
}
|
||||
if f.Product != "" && ev.Product != f.Product {
|
||||
continue
|
||||
}
|
||||
if f.ActorID != "" && ev.ActorID != f.ActorID {
|
||||
continue
|
||||
}
|
||||
if f.Action != "" && ev.Action != f.Action {
|
||||
continue
|
||||
}
|
||||
if f.Since != nil && ev.CreatedAt.Before(*f.Since) {
|
||||
continue
|
||||
}
|
||||
if f.Until != nil && ev.CreatedAt.After(*f.Until) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, *ev)
|
||||
if len(matches) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
var nextCursor int64
|
||||
if len(matches) == limit && len(matches) > 0 {
|
||||
nextCursor = matches[len(matches)-1].ID
|
||||
}
|
||||
return matches, nextCursor, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemory_seededAcme(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("by slug returns seed", func(t *testing.T) {
|
||||
got, err := m.BySlug(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Slug != "acme" {
|
||||
t.Errorf("slug = %q, want acme", got.Slug)
|
||||
}
|
||||
if got.Status != "active" {
|
||||
t.Errorf("status = %q, want active", got.Status)
|
||||
}
|
||||
if len(got.Products) != 2 {
|
||||
t.Errorf("products = %v, want [certifai compliance]", got.Products)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("by id returns seed", func(t *testing.T) {
|
||||
got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Slug != "acme" {
|
||||
t.Errorf("slug = %q, want acme", got.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing slug returns ErrNotFound", func(t *testing.T) {
|
||||
_, err := m.BySlug(ctx, "nope")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing id returns ErrNotFound", func(t *testing.T) {
|
||||
_, err := m.ByID(ctx, "deadbeef")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) {
|
||||
got, err := m.BySlug(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got.Name = "mutated"
|
||||
got2, _ := m.BySlug(ctx, "acme")
|
||||
if got2.Name == "mutated" {
|
||||
t.Error("store leaked internal pointer; caller could mutate seeded state")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgerrcode"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Postgres — pgxpool-backed Store. The schema this expects is produced by
|
||||
// the migrations/ package (M4.1 forward).
|
||||
type Postgres struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgres opens a pool and pings. Caller must Close().
|
||||
func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnLifetime = time.Hour
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return &Postgres{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) Close() { p.pool.Close() }
|
||||
func (p *Postgres) Ping(ctx context.Context) error { return p.pool.Ping(ctx) }
|
||||
|
||||
// isUniqueViolation detects Postgres unique_violation (23505) so callers
|
||||
// can return ErrConflict cleanly.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation
|
||||
}
|
||||
|
||||
// isCheckViolation detects check_constraint_violation (23514) — used by the
|
||||
// slug regex check + plan/status enum guards.
|
||||
func isCheckViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.CheckViolation
|
||||
}
|
||||
|
||||
// ─── tenants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const tenantSelect = `
|
||||
SELECT id::text, slug, name, status::text, kind::text, plan,
|
||||
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
||||
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
||||
created_at, updated_at
|
||||
FROM tenants `
|
||||
|
||||
func scanTenant(row pgx.Row) (*Tenant, error) {
|
||||
var t Tenant
|
||||
var trialEnds, cStart, cEnd *time.Time
|
||||
err := row.Scan(
|
||||
&t.ID, &t.Slug, &t.Name, &t.Status, &t.Kind, &t.Plan,
|
||||
&t.ErpCustomerID, &t.StripeCustID,
|
||||
&trialEnds, &cStart, &cEnd, &t.SalesOwner,
|
||||
&t.CreatedAt, &t.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.TrialEndsAt = trialEnds
|
||||
t.ContractStart = cStart
|
||||
t.ContractEnd = cEnd
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
||||
kind := firstNonEmpty(in.Kind, "customer")
|
||||
plan := firstNonEmpty(in.Plan, "starter")
|
||||
row := p.pool.QueryRow(ctx,
|
||||
`INSERT INTO tenants (slug, name, kind, plan, sales_owner)
|
||||
VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, ''))
|
||||
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
||||
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
||||
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
||||
created_at, updated_at`,
|
||||
in.Slug, in.Name, kind, plan, in.SalesOwner,
|
||||
)
|
||||
t, err := scanTenant(row)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
if isCheckViolation(err) {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
return nil, fmt.Errorf("create tenant: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) GetTenant(ctx context.Context, id string) (*Tenant, error) {
|
||||
return scanTenant(p.pool.QueryRow(ctx, tenantSelect+`WHERE id = $1::uuid`, id))
|
||||
}
|
||||
|
||||
func (p *Postgres) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) {
|
||||
return scanTenant(p.pool.QueryRow(ctx, tenantSelect+`WHERE slug = $1`, slug))
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateTenant(ctx context.Context, id string, in TenantUpdate) (*Tenant, error) {
|
||||
// Build a partial UPDATE via COALESCE on each nullable input. Reads each
|
||||
// field once; trivially type-safe.
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
UPDATE tenants SET
|
||||
status = COALESCE($2::tenant_status, status),
|
||||
plan = COALESCE($3, plan),
|
||||
erp_customer_id = COALESCE($4, erp_customer_id),
|
||||
stripe_cust_id = COALESCE($5, stripe_cust_id),
|
||||
trial_ends_at = COALESCE($6, trial_ends_at),
|
||||
contract_start = COALESCE($7, contract_start),
|
||||
contract_end = COALESCE($8, contract_end),
|
||||
sales_owner = COALESCE($9, sales_owner)
|
||||
WHERE id = $1::uuid
|
||||
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
||||
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
||||
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
||||
created_at, updated_at`,
|
||||
id,
|
||||
nullableStr(in.Status), nullableStr(in.Plan),
|
||||
nullableStr(in.ErpCustomerID), nullableStr(in.StripeCustID),
|
||||
nullableTime(in.TrialEndsAt), nullableTime(in.ContractStart), nullableTime(in.ContractEnd),
|
||||
nullableStr(in.SalesOwner),
|
||||
)
|
||||
t, err := scanTenant(row)
|
||||
if err != nil {
|
||||
if isCheckViolation(err) {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ─── entitlements ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Postgres) UpsertTenantProduct(ctx context.Context, tp TenantProduct) (*TenantProduct, error) {
|
||||
cfg, err := json.Marshal(tp.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
if cfg == nil || string(cfg) == "null" {
|
||||
cfg = []byte("{}")
|
||||
}
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
INSERT INTO tenant_products (tenant_id, product, enabled, config, expires_at)
|
||||
VALUES ($1::uuid, $2, $3, $4::jsonb, $5)
|
||||
ON CONFLICT (tenant_id, product) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
config = EXCLUDED.config,
|
||||
expires_at = EXCLUDED.expires_at
|
||||
RETURNING tenant_id::text, product, enabled, config, expires_at, created_at, updated_at`,
|
||||
tp.TenantID, tp.Product, tp.Enabled, cfg, tp.ExpiresAt,
|
||||
)
|
||||
var out TenantProduct
|
||||
var rawCfg []byte
|
||||
var expires *time.Time
|
||||
err = row.Scan(&out.TenantID, &out.Product, &out.Enabled, &rawCfg, &expires, &out.CreatedAt, &out.UpdatedAt)
|
||||
if err != nil {
|
||||
// FK violation on tenant_id → not found
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out.ExpiresAt = expires
|
||||
if err := json.Unmarshal(rawCfg, &out.Config); err != nil {
|
||||
out.Config = map[string]interface{}{}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListTenantProducts(ctx context.Context, tenantID string) ([]TenantProduct, error) {
|
||||
// First confirm tenant exists so we can return ErrNotFound consistent with Memory.
|
||||
if _, err := p.GetTenant(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT tenant_id::text, product, enabled, config, expires_at, created_at, updated_at
|
||||
FROM tenant_products WHERE tenant_id = $1::uuid ORDER BY product`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TenantProduct{}
|
||||
for rows.Next() {
|
||||
var tp TenantProduct
|
||||
var rawCfg []byte
|
||||
var expires *time.Time
|
||||
if err := rows.Scan(&tp.TenantID, &tp.Product, &tp.Enabled, &rawCfg, &expires, &tp.CreatedAt, &tp.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp.ExpiresAt = expires
|
||||
if err := json.Unmarshal(rawCfg, &tp.Config); err != nil {
|
||||
tp.Config = map[string]interface{}{}
|
||||
}
|
||||
out = append(out, tp)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ─── api keys ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Postgres) CreateAPIKey(ctx context.Context, in APIKeyCreate) (*APIKey, error) {
|
||||
var product any
|
||||
if in.Product != "" {
|
||||
product = in.Product
|
||||
}
|
||||
var createdBy any
|
||||
if in.CreatedBy != "" {
|
||||
createdBy = in.CreatedBy
|
||||
}
|
||||
// Coerce nil to an empty slice — the schema's NOT NULL DEFAULT only
|
||||
// fires when the column is omitted, not when an explicit NULL is sent.
|
||||
scopes := in.Scopes
|
||||
if scopes == nil {
|
||||
scopes = []string{}
|
||||
}
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
INSERT INTO api_keys (tenant_id, product, name, scopes, hash, prefix, created_by)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id::text, tenant_id::text, COALESCE(product,''), name, scopes, prefix,
|
||||
COALESCE(created_by,''), last_used_at, revoked_at, created_at`,
|
||||
in.TenantID, product, in.Name, scopes, in.Hash, in.Prefix, createdBy,
|
||||
)
|
||||
k, err := scanAPIKey(row)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.ForeignKeyViolation {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
func scanAPIKey(row pgx.Row) (*APIKey, error) {
|
||||
var k APIKey
|
||||
var lastUsed, revoked *time.Time
|
||||
err := row.Scan(&k.ID, &k.TenantID, &k.Product, &k.Name, &k.Scopes, &k.Prefix,
|
||||
&k.CreatedBy, &lastUsed, &revoked, &k.CreatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.LastUsedAt = lastUsed
|
||||
k.RevokedAt = revoked
|
||||
return &k, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) FindAPIKeyByPrefix(ctx context.Context, prefix string) (*APIKey, string, error) {
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, tenant_id::text, COALESCE(product,''), name, scopes, prefix,
|
||||
COALESCE(created_by,''), last_used_at, revoked_at, created_at, hash
|
||||
FROM api_keys WHERE prefix = $1 AND revoked_at IS NULL`, prefix)
|
||||
var k APIKey
|
||||
var lastUsed, revoked *time.Time
|
||||
var hash string
|
||||
err := row.Scan(&k.ID, &k.TenantID, &k.Product, &k.Name, &k.Scopes, &k.Prefix,
|
||||
&k.CreatedBy, &lastUsed, &revoked, &k.CreatedAt, &hash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
k.LastUsedAt = lastUsed
|
||||
k.RevokedAt = revoked
|
||||
return &k, hash, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) TouchAPIKeyUsed(ctx context.Context, id string) error {
|
||||
tag, err := p.pool.Exec(ctx, `UPDATE api_keys SET last_used_at = NOW() WHERE id = $1::uuid`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) RevokeAPIKey(ctx context.Context, id string) error {
|
||||
tag, err := p.pool.Exec(ctx, `UPDATE api_keys SET revoked_at = NOW() WHERE id = $1::uuid AND revoked_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListAPIKeys(ctx context.Context, tenantID string) ([]APIKey, error) {
|
||||
if _, err := p.GetTenant(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id::text, tenant_id::text, COALESCE(product,''), name, scopes, prefix,
|
||||
COALESCE(created_by,''), last_used_at, revoked_at, created_at
|
||||
FROM api_keys WHERE tenant_id = $1::uuid ORDER BY created_at DESC`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []APIKey{}
|
||||
for rows.Next() {
|
||||
var k APIKey
|
||||
var lastUsed, revoked *time.Time
|
||||
if err := rows.Scan(&k.ID, &k.TenantID, &k.Product, &k.Name, &k.Scopes, &k.Prefix,
|
||||
&k.CreatedBy, &lastUsed, &revoked, &k.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.LastUsedAt = lastUsed
|
||||
k.RevokedAt = revoked
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ─── audit ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Postgres) AppendAudit(ctx context.Context, ev AuditEvent) (*AuditEvent, error) {
|
||||
meta, _ := json.Marshal(ev.Metadata)
|
||||
if meta == nil || string(meta) == "null" {
|
||||
meta = []byte("{}")
|
||||
}
|
||||
row := p.pool.QueryRow(ctx, `
|
||||
INSERT INTO audit_log
|
||||
(tenant_id, project_id, actor_id, actor_name, actor_type, action,
|
||||
target_id, target_type, target_name, product, metadata, source_ip, user_agent)
|
||||
VALUES
|
||||
(NULLIF($1,'')::uuid, NULLIF($2,'')::uuid, NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), $6,
|
||||
NULLIF($7,''), NULLIF($8,''), NULLIF($9,''), NULLIF($10,''), $11::jsonb,
|
||||
NULLIF($12,'')::inet, NULLIF($13,''))
|
||||
RETURNING id, COALESCE(tenant_id::text,''), COALESCE(project_id::text,''),
|
||||
COALESCE(actor_id,''), COALESCE(actor_name,''), COALESCE(actor_type,''),
|
||||
action, COALESCE(target_id,''), COALESCE(target_type,''), COALESCE(target_name,''),
|
||||
COALESCE(product,''), metadata, COALESCE(host(source_ip),''), COALESCE(user_agent,''),
|
||||
created_at`,
|
||||
ev.TenantID, ev.ProjectID, ev.ActorID, ev.ActorName, ev.ActorType, ev.Action,
|
||||
ev.TargetID, ev.TargetType, ev.TargetName, ev.Product, meta, ev.SourceIP, ev.UserAgent,
|
||||
)
|
||||
out, err := scanAudit(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanAudit(row pgx.Row) (*AuditEvent, error) {
|
||||
var ev AuditEvent
|
||||
var rawMeta []byte
|
||||
err := row.Scan(
|
||||
&ev.ID, &ev.TenantID, &ev.ProjectID,
|
||||
&ev.ActorID, &ev.ActorName, &ev.ActorType,
|
||||
&ev.Action, &ev.TargetID, &ev.TargetType, &ev.TargetName,
|
||||
&ev.Product, &rawMeta, &ev.SourceIP, &ev.UserAgent,
|
||||
&ev.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rawMeta) > 0 {
|
||||
if err := json.Unmarshal(rawMeta, &ev.Metadata); err != nil {
|
||||
ev.Metadata = map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
return &ev, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, int64, error) {
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
// Build WHERE clauses dynamically — keep param indices stable.
|
||||
where := []string{"id > $1"}
|
||||
args := []any{f.Cursor}
|
||||
add := func(clause string, v any) {
|
||||
args = append(args, v)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if f.TenantID != "" {
|
||||
add("tenant_id = $%d::uuid", f.TenantID)
|
||||
}
|
||||
if f.Product != "" {
|
||||
add("product = $%d", f.Product)
|
||||
}
|
||||
if f.ActorID != "" {
|
||||
add("actor_id = $%d", f.ActorID)
|
||||
}
|
||||
if f.Action != "" {
|
||||
add("action = $%d", f.Action)
|
||||
}
|
||||
if f.Since != nil {
|
||||
add("created_at >= $%d", *f.Since)
|
||||
}
|
||||
if f.Until != nil {
|
||||
add("created_at <= $%d", *f.Until)
|
||||
}
|
||||
args = append(args, limit)
|
||||
sql := `
|
||||
SELECT id, COALESCE(tenant_id::text,''), COALESCE(project_id::text,''),
|
||||
COALESCE(actor_id,''), COALESCE(actor_name,''), COALESCE(actor_type,''),
|
||||
action, COALESCE(target_id,''), COALESCE(target_type,''), COALESCE(target_name,''),
|
||||
COALESCE(product,''), metadata, COALESCE(host(source_ip),''), COALESCE(user_agent,''),
|
||||
created_at
|
||||
FROM audit_log
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY id ASC
|
||||
LIMIT $` + fmt.Sprintf("%d", len(args))
|
||||
rows, err := p.pool.Query(ctx, sql, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []AuditEvent{}
|
||||
for rows.Next() {
|
||||
ev, err := scanAudit(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, *ev)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var nextCursor int64
|
||||
if len(out) == limit && len(out) > 0 {
|
||||
nextCursor = out[len(out)-1].ID
|
||||
}
|
||||
return out, nextCursor, nil
|
||||
}
|
||||
|
||||
func nullableStr(p *string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
func nullableTime(p *time.Time) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Package store hides the persistence layer behind a Store interface.
|
||||
// Two implementations: Memory (dev convenience, used when DATABASE_URL is
|
||||
// empty) and Postgres (production via pgx). Handlers depend on the
|
||||
// interface — never on a concrete type.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sentinel errors.
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// TenantCreate is the input shape for Store.CreateTenant.
|
||||
type TenantCreate struct {
|
||||
Slug string
|
||||
Name string
|
||||
Plan string // optional, defaults to "starter"
|
||||
Kind string // optional, defaults to "customer"
|
||||
SalesOwner string // optional
|
||||
}
|
||||
|
||||
// TenantUpdate captures partial mutations. Nil fields are left untouched.
|
||||
type TenantUpdate struct {
|
||||
Status *string
|
||||
Plan *string
|
||||
ErpCustomerID *string
|
||||
StripeCustID *string
|
||||
TrialEndsAt *time.Time
|
||||
ContractStart *time.Time
|
||||
ContractEnd *time.Time
|
||||
SalesOwner *string
|
||||
}
|
||||
|
||||
// APIKeyCreate is the input shape for Store.CreateAPIKey.
|
||||
type APIKeyCreate struct {
|
||||
TenantID string
|
||||
Product string // empty = applies to all products
|
||||
Name string
|
||||
Scopes []string
|
||||
Prefix string
|
||||
Hash string // argon2id encoded
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
// AuditFilter narrows /v1/audit GET results.
|
||||
type AuditFilter struct {
|
||||
TenantID string
|
||||
Product string
|
||||
ActorID string
|
||||
Action string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
Cursor int64 // id > Cursor (ascending) is the next page anchor
|
||||
}
|
||||
|
||||
// Store is the persistence contract. Implementations:
|
||||
// - Memory — in-process, used when DATABASE_URL is empty (dev convenience).
|
||||
// - Postgres — pgxpool-backed, used in stage + prod.
|
||||
type Store interface {
|
||||
// Tenants
|
||||
CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error)
|
||||
GetTenant(ctx context.Context, id string) (*Tenant, error)
|
||||
GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error)
|
||||
UpdateTenant(ctx context.Context, id string, in TenantUpdate) (*Tenant, error)
|
||||
|
||||
// Entitlements
|
||||
UpsertTenantProduct(ctx context.Context, tp TenantProduct) (*TenantProduct, error)
|
||||
ListTenantProducts(ctx context.Context, tenantID string) ([]TenantProduct, error)
|
||||
|
||||
// API keys
|
||||
CreateAPIKey(ctx context.Context, in APIKeyCreate) (*APIKey, error)
|
||||
FindAPIKeyByPrefix(ctx context.Context, prefix string) (*APIKey, string, error) // returns key + hash
|
||||
TouchAPIKeyUsed(ctx context.Context, id string) error
|
||||
RevokeAPIKey(ctx context.Context, id string) error
|
||||
ListAPIKeys(ctx context.Context, tenantID string) ([]APIKey, error)
|
||||
|
||||
// Audit
|
||||
AppendAudit(ctx context.Context, ev AuditEvent) (*AuditEvent, error)
|
||||
ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, int64, error) // returns rows + next cursor (0 = none)
|
||||
|
||||
// Lifecycle
|
||||
Close()
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
// Tenant — root entity. Lifecycle states per PLATFORM_ARCHITECTURE.md §5c:
|
||||
//
|
||||
// demo — shared demo tenant; reset nightly; no billing
|
||||
// trial — real customer in N-day evaluation window
|
||||
// active — paid; contract or self-serve
|
||||
// frozen — read-only after cancel / non-payment (30d grace)
|
||||
// archived — data export window closed; only audit_log retained
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Kind string `json:"kind"`
|
||||
Plan string `json:"plan"`
|
||||
ErpCustomerID string `json:"erp_customer_id,omitempty"`
|
||||
StripeCustID string `json:"stripe_cust_id,omitempty"`
|
||||
TrialEndsAt *time.Time `json:"trial_ends_at,omitempty"`
|
||||
ContractStart *time.Time `json:"contract_start,omitempty"`
|
||||
ContractEnd *time.Time `json:"contract_end,omitempty"`
|
||||
SalesOwner string `json:"sales_owner,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TenantProduct — entitlement matrix row.
|
||||
type TenantProduct struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Product string `json:"product"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// APIKey — portal-owned. Plaintext key is shown ONCE on creation;
|
||||
// stored as argon2id hash + prefix for UI display.
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Prefix string `json:"prefix"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AuditEvent — Retraced-shape per PRODUCT_INTEGRATION_SPEC.md §8.4.
|
||||
type AuditEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ActorID string `json:"actor_id,omitempty"`
|
||||
ActorName string `json:"actor_name,omitempty"`
|
||||
ActorType string `json:"actor_type,omitempty"`
|
||||
Action string `json:"action"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetName string `json:"target_name,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CatalogEntry — what /v1/catalog returns per available product.
|
||||
type CatalogEntry struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
PlansRequired []string `json:"plans_required"`
|
||||
DemoURL string `json:"demo_url,omitempty"`
|
||||
SupportsTrial bool `json:"supports_trial"`
|
||||
}
|
||||
Reference in New Issue
Block a user