Compare commits
3 Commits
fd5f8ae36f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fa1a1bffd | |||
| a37ae1d121 | |||
| 9138731eea |
@@ -83,7 +83,7 @@ jobs:
|
|||||||
# own test binary — and including it triggers a covdata-tool error
|
# own test binary — and including it triggers a covdata-tool error
|
||||||
# on packages with no _test.go files. -coverpkg makes the server's
|
# on packages with no _test.go files. -coverpkg makes the server's
|
||||||
# exercise of store/* count toward coverage.
|
# exercise of store/* count toward coverage.
|
||||||
run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/...
|
run: go test -race -coverpkg=./internal/... -coverprofile=cover.out ./internal/server/... ./internal/config/... ./internal/keycloak/...
|
||||||
|
|
||||||
- name: coverage gate
|
- name: coverage gate
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- feat(store): CreateTenant defaults trial_ends_at to NOW()+14d for customer kind; demo kind gets status='demo' and no trial end
|
||||||
- feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant.
|
- feat(keycloak): M4.3 — internal/keycloak adapter (Admin API: org create + IT_ADMIN invite + execute-actions-email + attribute sync). admin_email on POST /v1/tenants triggers KC provisioning; failures emit keycloak.provision_failed audit but don't roll back. POST /v1/internal/keycloak/claims resolves the current claim bundle for a tenant.
|
||||||
- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test.
|
- feat(api): M4.2 — full REST surface (tenants CRUD + lifecycle, catalog, entitlements, API keys w/ argon2 hashing, audit query). pgx-backed Postgres store; in-memory fallback when DATABASE_URL is empty. OpenAPI 3.1 spec at openapi.yaml with kin-openapi contract test.
|
||||||
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
- feat(schema): M4.1 — golang-migrate migrations for tenants + tenant_projects + tenant_products + tenant_idp_config + api_keys + audit_log; cmd/migrate binary; testcontainers round-trip + seed + slug-constraint tests
|
||||||
|
|||||||
@@ -166,3 +166,4 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md). TL;DR: branch from main, open a PR,
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE).
|
Proprietary — all rights reserved. Copyright (c) 2026 Sharang Parnerkar and Benjamin Boenisch. See [`LICENSE`](./LICENSE).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package keycloak
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubKC builds a minimal KC look-alike: token endpoint + the Admin API
|
||||||
|
// paths the HTTPAdapter actually calls. Each path is a single handler that
|
||||||
|
// asserts the request shape and returns the bare-minimum valid response.
|
||||||
|
type stubKC struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
tokenCalls atomic.Int32
|
||||||
|
orgCalls atomic.Int32
|
||||||
|
userCalls atomic.Int32
|
||||||
|
memberCalls atomic.Int32
|
||||||
|
emailCalls atomic.Int32
|
||||||
|
healthCalls atomic.Int32
|
||||||
|
syncCalls atomic.Int32
|
||||||
|
|
||||||
|
tokenFails atomic.Bool // when true, /token returns 401 once
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStubKC(t *testing.T) *stubKC {
|
||||||
|
t.Helper()
|
||||||
|
s := &stubKC{}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.tokenCalls.Add(1)
|
||||||
|
if s.tokenFails.Swap(false) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"access_token": "test-token", "expires_in": 60,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/serverinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.healthCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"systemInfo":{"version":"26.0.0"}}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.orgCalls.Add(1)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/organizations/org-xyz")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/organizations/org-xyz/members", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.memberCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/users", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.userCalls.Add(1)
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
w.Header().Set("Location", s.srv.URL+"/admin/realms/test-realm/users/user-abc")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/users/user-abc/execute-actions-email", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.emailCalls.Add(1)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/users/user-abc", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.syncCalls.Add(1)
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s.srv = httptest.NewServer(mux)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubKC) close() { s.srv.Close() }
|
||||||
|
|
||||||
|
func newTestAdapter(srv *httptest.Server) *HTTPAdapter {
|
||||||
|
return NewHTTPAdapter(HTTPConfig{
|
||||||
|
BaseURL: srv.URL,
|
||||||
|
Realm: "test-realm",
|
||||||
|
ClientID: "test-client",
|
||||||
|
ClientSecret: "test-secret",
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_health(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
if err := a.Health(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.healthCalls.Load() != 1 {
|
||||||
|
t.Errorf("health calls = %d", s.healthCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_createOrgAndInvite(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
res, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||||
|
TenantID: "t1", Slug: "acme", Name: "Acme Inc.",
|
||||||
|
AdminEmail: "owner@acme.test", AdminName: "Alice Owner",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.OrganizationID != "org-xyz" || res.UserID != "user-abc" {
|
||||||
|
t.Errorf("unexpected ids: %+v", res)
|
||||||
|
}
|
||||||
|
if s.orgCalls.Load() != 1 || s.userCalls.Load() != 1 ||
|
||||||
|
s.memberCalls.Load() != 1 || s.emailCalls.Load() != 1 {
|
||||||
|
t.Errorf("call counts: org=%d user=%d member=%d email=%d",
|
||||||
|
s.orgCalls.Load(), s.userCalls.Load(), s.memberCalls.Load(), s.emailCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_emailMissingAdminEmailRejected(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
_, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||||
|
TenantID: "t1", Slug: "x", Name: "X",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty admin email")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_orgConflict(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", tokenOK)
|
||||||
|
mux.HandleFunc("/admin/realms/test-realm/organizations", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
a := newTestAdapter(srv)
|
||||||
|
|
||||||
|
_, err := a.CreateOrgAndInvite(context.Background(), InviteInput{
|
||||||
|
TenantID: "t1", Slug: "x", Name: "X", AdminEmail: "a@b.test",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, ErrOrgConflict) {
|
||||||
|
t.Errorf("err = %v, want ErrOrgConflict", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_tokenUnavailable(t *testing.T) {
|
||||||
|
// No KC server at all — adapter should surface ErrUnavailable.
|
||||||
|
a := NewHTTPAdapter(HTTPConfig{
|
||||||
|
BaseURL: "http://127.0.0.1:1", Realm: "test", ClientID: "x", ClientSecret: "y", Timeout: 1 * time.Second,
|
||||||
|
})
|
||||||
|
err := a.Health(context.Background())
|
||||||
|
if !errors.Is(err, ErrUnavailable) {
|
||||||
|
t.Errorf("err = %v, want ErrUnavailable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_tokenUnauthorized(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/realms/test-realm/protocol/openid-connect/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
a := newTestAdapter(srv)
|
||||||
|
err := a.Health(context.Background())
|
||||||
|
if !errors.Is(err, ErrUnauthorized) {
|
||||||
|
t.Errorf("err = %v, want ErrUnauthorized", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_syncClaims(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
err := a.SyncClaims(context.Background(), "user-abc", Claims{
|
||||||
|
TenantID: "t1", TenantSlug: "acme", Plan: "professional",
|
||||||
|
Products: []string{"certifai"}, TenantStatus: "active",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.syncCalls.Load() != 1 {
|
||||||
|
t.Errorf("sync calls = %d", s.syncCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPAdapter_tokenIsCached(t *testing.T) {
|
||||||
|
s := newStubKC(t)
|
||||||
|
defer s.close()
|
||||||
|
a := newTestAdapter(s.srv)
|
||||||
|
|
||||||
|
// Three Health calls should produce ONE token fetch (cached).
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := a.Health(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.tokenCalls.Load() != 1 {
|
||||||
|
t.Errorf("token fetches = %d, want 1 (cache miss)", s.tokenCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenOK is a reusable handler that always returns a working token.
|
||||||
|
func tokenOK(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := make([]byte, r.ContentLength)
|
||||||
|
_, _ = r.Body.Read(b)
|
||||||
|
if !strings.Contains(string(b), "client_credentials") {
|
||||||
|
http.Error(w, "grant_type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"access_token":"t","expires_in":60}`))
|
||||||
|
}
|
||||||
+13
-11
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -87,22 +88,23 @@ func (s *statusRecorder) WriteHeader(c int) {
|
|||||||
func clientIP(r *http.Request) string {
|
func clientIP(r *http.Request) string {
|
||||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||||
return strings.TrimSpace(fwd[:i])
|
return stripBrackets(strings.TrimSpace(fwd[:i]))
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(fwd)
|
return stripBrackets(strings.TrimSpace(fwd))
|
||||||
}
|
}
|
||||||
if host, _, ok := splitHostPort(r.RemoteAddr); ok {
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
// net.SplitHostPort returns IPv6 without brackets already.
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
return r.RemoteAddr
|
return stripBrackets(r.RemoteAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitHostPort is a port-tolerant version of net.SplitHostPort that doesn't
|
// stripBrackets removes the `[...]` wrapping IPv6 hosts pick up from
|
||||||
// error on missing port.
|
// net/http's RemoteAddr in some Go versions, since Postgres `inet` rejects
|
||||||
func splitHostPort(s string) (string, string, bool) {
|
// `[::1]` but accepts `::1`.
|
||||||
i := strings.LastIndexByte(s, ':')
|
func stripBrackets(s string) string {
|
||||||
if i < 0 {
|
if len(s) >= 2 && s[0] == '[' && s[len(s)-1] == ']' {
|
||||||
return s, "", false
|
return s[1 : len(s)-1]
|
||||||
}
|
}
|
||||||
return s[:i], s[i+1:], true
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func TestCreateTenant_kcFailure_doesNotRollback(t *testing.T) {
|
|||||||
t.Error("tenant id missing")
|
t.Error("tenant id missing")
|
||||||
}
|
}
|
||||||
// And there's a provision_failed audit event for it.
|
// And there's a provision_failed audit event for it.
|
||||||
resp, body = h.do("GET",
|
_, body = h.do("GET",
|
||||||
"/v1/audit?action=keycloak.provision_failed&tenant_id="+out.Tenant.ID, nil)
|
"/v1/audit?action=keycloak.provision_failed&tenant_id="+out.Tenant.ID, nil)
|
||||||
listed := decode[struct {
|
listed := decode[struct {
|
||||||
Items []store.AuditEvent `json:"items"`
|
Items []store.AuditEvent `json:"items"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server_test
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||||
)
|
)
|
||||||
@@ -119,3 +120,42 @@ func TestCancelTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTenant_setsTrialEndsAt(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
|
"slug": "trial-ends-co", "name": "Trial Ends Co.",
|
||||||
|
})
|
||||||
|
out := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
if out.Tenant.Status != "trial" {
|
||||||
|
t.Fatalf("status = %q, want trial", out.Tenant.Status)
|
||||||
|
}
|
||||||
|
if out.Tenant.TrialEndsAt == nil {
|
||||||
|
t.Fatal("trial_ends_at is nil; should be ~14 days from now")
|
||||||
|
}
|
||||||
|
// Sanity-check: ends_at is in the future, within 13.5-14.5 days.
|
||||||
|
delta := time.Until(*out.Tenant.TrialEndsAt)
|
||||||
|
if delta < 13*24*time.Hour || delta > 15*24*time.Hour {
|
||||||
|
t.Errorf("trial_ends_at offset = %v, want ~14d", delta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTenant_demoKindHasNoTrialEnd(t *testing.T) {
|
||||||
|
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||||
|
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||||
|
"slug": "demo-co", "name": "Demo", "kind": "demo",
|
||||||
|
})
|
||||||
|
out := decode[struct {
|
||||||
|
Tenant *store.Tenant `json:"tenant"`
|
||||||
|
}](t, body)
|
||||||
|
if out.Tenant.Status != "demo" {
|
||||||
|
t.Errorf("status = %q, want demo", out.Tenant.Status)
|
||||||
|
}
|
||||||
|
if out.Tenant.TrialEndsAt != nil {
|
||||||
|
t.Errorf("trial_ends_at = %v, want nil for demo kind", out.Tenant.TrialEndsAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,14 +69,24 @@ func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, erro
|
|||||||
return nil, ErrConflict
|
return nil, ErrConflict
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
kind := firstNonEmpty(in.Kind, "customer")
|
||||||
|
status := "trial"
|
||||||
|
var trialEnds *time.Time
|
||||||
|
if kind == "demo" {
|
||||||
|
status = "demo"
|
||||||
|
} else {
|
||||||
|
end := now.Add(14 * 24 * time.Hour)
|
||||||
|
trialEnds = &end
|
||||||
|
}
|
||||||
t := &Tenant{
|
t := &Tenant{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
Slug: in.Slug,
|
Slug: in.Slug,
|
||||||
Name: in.Name,
|
Name: in.Name,
|
||||||
Status: "trial",
|
Status: status,
|
||||||
Kind: firstNonEmpty(in.Kind, "customer"),
|
Kind: kind,
|
||||||
Plan: firstNonEmpty(in.Plan, "starter"),
|
Plan: firstNonEmpty(in.Plan, "starter"),
|
||||||
SalesOwner: in.SalesOwner,
|
SalesOwner: in.SalesOwner,
|
||||||
|
TrialEndsAt: trialEnds,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,20 @@ func scanTenant(row pgx.Row) (*Tenant, error) {
|
|||||||
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
func (p *Postgres) CreateTenant(ctx context.Context, in TenantCreate) (*Tenant, error) {
|
||||||
kind := firstNonEmpty(in.Kind, "customer")
|
kind := firstNonEmpty(in.Kind, "customer")
|
||||||
plan := firstNonEmpty(in.Plan, "starter")
|
plan := firstNonEmpty(in.Plan, "starter")
|
||||||
|
// Default status = 'trial'; set trial_ends_at = NOW() + 14 days so the
|
||||||
|
// portal's trial banner has a real countdown to render. Demo tenants
|
||||||
|
// (kind=demo) get status='demo' and no trial_ends_at — that's set by
|
||||||
|
// the M13.2 demo provisioning path.
|
||||||
row := p.pool.QueryRow(ctx,
|
row := p.pool.QueryRow(ctx,
|
||||||
`INSERT INTO tenants (slug, name, kind, plan, sales_owner)
|
`INSERT INTO tenants (slug, name, kind, plan, status, sales_owner, trial_ends_at)
|
||||||
VALUES ($1, $2, $3::tenant_kind, $4, NULLIF($5, ''))
|
VALUES (
|
||||||
|
$1, $2, $3::tenant_kind, $4,
|
||||||
|
CASE WHEN $3::tenant_kind = 'demo' THEN 'demo'::tenant_status
|
||||||
|
ELSE 'trial'::tenant_status END,
|
||||||
|
NULLIF($5, ''),
|
||||||
|
CASE WHEN $3::tenant_kind = 'demo' THEN NULL
|
||||||
|
ELSE NOW() + INTERVAL '14 days' END
|
||||||
|
)
|
||||||
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
RETURNING id::text, slug, name, status::text, kind::text, plan,
|
||||||
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
COALESCE(erp_customer_id,''), COALESCE(stripe_cust_id,''),
|
||||||
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
trial_ends_at, contract_start, contract_end, COALESCE(sales_owner,''),
|
||||||
|
|||||||
Reference in New Issue
Block a user