Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,9 @@ rc products store screenshot <product-id> # upload an App Review
rc paywalls # help; under npx a TTY shows a generate/edit picker (npm launcher sets RC_GUIDED — paywalls-only for now)
rc paywalls list
rc paywalls show <id>
rc paywalls screens <paywall-id> # list a paywall's screens (id, name, position, purchase screen) from its graph
rc paywalls generate [--offering-id <id>] --prompt "..." # create a paywall; standalone unless --offering-id attaches it to an offering
rc paywalls edit <paywall-id>|--session <file> --prompt # AI-edit any paywall (draft components fetched via v2) or continue a session
rc paywalls edit <paywall-id>|--session <file> --prompt [--step-id <id>] # AI-edit any paywall (draft components fetched via v2, or via the graph for --step-id) or continue a session
rc paywalls rewind --session <file> # undo the last editor action
rc paywalls publish [id] # publish the current draft; confirmation/--yes
rc paywalls unpublish [id] # remove the published paywall; confirmation/--yes
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/cli-coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ endpoints:
path: /projects/{project_id}/paywalls/{paywall_id}/actions/unpublish
- method: DELETE
path: /projects/{project_id}/paywalls/{paywall_id}
- method: GET
path: /projects/{project_id}/paywalls/{paywall_id}/graph

# Media assets
- method: GET
Expand Down
103 changes: 103 additions & 0 deletions internal/api/paywalls.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"net/url"
)

type PaywallsService struct{ c *Client }
Expand Down Expand Up @@ -118,3 +119,105 @@ func (s *PaywallsService) Unpublish(ctx context.Context, projectID, id string) (
func (s *PaywallsService) Delete(ctx context.Context, projectID, id string) error {
return s.c.do(ctx, http.MethodDelete, pathPaywall(projectID, id), nil, nil)
}

// PaywallGraph is the envelope from GET .../paywalls/{id}/graph. Graph is nil
// only for a genuinely standalone V2 paywall with no screen graph.
//
// Hand-written, not generated: this route isn't in the vendored OpenAPI spec
// yet, so it's listed in scripts/gen-paths.py's NON_SPEC_PATHS instead of
// getting a generated type.
type PaywallGraph struct {
Object string `json:"object"`
ID string `json:"id"`
Version string `json:"version"`
Graph *Graph `json:"graph"`
}

// Graph describes a paywall's screens and how they connect. PaywallStepID is
// the authoritative purchase screen; a step's IsTerminal means "no outgoing
// edges", not "is the purchase screen" — do not conflate the two.
type Graph struct {
Revision *int `json:"revision"`
InitialStepID *string `json:"initial_step_id"`
PaywallStepID string `json:"paywall_step_id"`
TotalSteps int `json:"total_steps"`
Steps []GraphStep `json:"steps"`
}

// GraphStep is one node in the graph. Paywall carries the step's editable
// content and is present only when the graph is fetched with
// expand=graph.steps.paywall.
type GraphStep struct {
ID string `json:"id"`
Name *string `json:"name"`
Type string `json:"type"`
ScreenTypes []string `json:"screen_types"`
IsTerminal bool `json:"is_terminal"`
PaywallID *string `json:"paywall_id"`
Edges []GraphEdge `json:"edges"`
UnwiredTriggers []UnwiredTrigger `json:"unwired_triggers"`
Paywall *ScreenContent `json:"paywall,omitempty"`
}

type GraphEdge struct {
To string `json:"to"`
TriggerID string `json:"trigger_id"`
Condition json.RawMessage `json:"condition"`
IsDefault bool `json:"is_default"`
TriggerComponentID *string `json:"trigger_component_id"`
TriggerName *string `json:"trigger_name"`
TriggerType *string `json:"trigger_type"`
}

type UnwiredTrigger struct {
ActionID string `json:"action_id"`
ComponentID *string `json:"component_id"`
Name *string `json:"name"`
Type *string `json:"type"`
Reason string `json:"reason"`
}

// ScreenContent is a graph step's editable content — the same fields the
// dashboard's paywall editor reads and writes, scoped to one screen.
type ScreenContent struct {
ID string `json:"id"`
Revision int `json:"revision"`
ComponentsConfig json.RawMessage `json:"components_config"`
ComponentsLocalizations json.RawMessage `json:"components_localizations"`
DefaultLocale string `json:"default_locale"`
StateDeclarations json.RawMessage `json:"state_declarations"`
}

// GetGraph fetches a paywall's screen topology (no screen content). version
// is "draft" or "published".
func (s *PaywallsService) GetGraph(ctx context.Context, projectID, id, version string) (*PaywallGraph, error) {
path := encodePath("projects", projectID, "paywalls", id, "graph") + "?version=" + url.QueryEscape(version)
var out PaywallGraph
err := s.c.do(ctx, http.MethodGet, path, nil, &out)
return &out, err
}

// GetGraphWithScreenContent fetches the graph with each step's editable
// content included — the only way to read a sibling screen's content; the
// plain paywall GET can only ever return the fallback screen's own version.
func (s *PaywallsService) GetGraphWithScreenContent(ctx context.Context, projectID, id, version string) (*PaywallGraph, error) {
path := encodePath("projects", projectID, "paywalls", id, "graph") +
"?version=" + url.QueryEscape(version) + "&expand=graph.steps.paywall"
var out PaywallGraph
err := s.c.do(ctx, http.MethodGet, path, nil, &out)
return &out, err
}

// UpdateDraftStep saves component state onto one screen selected from the
// paywall's graph instead of onto its fallback draft. parentID stays in the
// path; the target screen is addressed only by the step_id query param, and
// the response's id is that screen's own canonical id, which can differ from
// parentID. The API rejects name for a sibling screen, so this clears Name
// regardless of what the caller set.
func (s *PaywallsService) UpdateDraftStep(ctx context.Context, projectID, parentID, stepID string, body PaywallDraftUpdate) (*Paywall, error) {
body.Name = nil
path := pathPaywall(projectID, parentID) + "?step_id=" + url.QueryEscape(stepID)
var out Paywall
err := s.c.do(ctx, http.MethodPatch, path, body, &out)
return &out, err
}
117 changes: 117 additions & 0 deletions internal/api/paywalls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,123 @@ func TestPaywallsSetOffering(t *testing.T) {
}
}

func TestPaywallsGetGraph(t *testing.T) {
tests := []struct {
name string
fetch func(client *api.Client) (*api.PaywallGraph, error)
wantQuery string
response string
wantGraph bool
wantSteps int
wantScreen bool // first step's Paywall content present
}{
{
name: "draft, no expand",
fetch: func(client *api.Client) (*api.PaywallGraph, error) {
return client.Paywalls.GetGraph(context.Background(), "proj", "pw", "draft")
},
wantQuery: "version=draft",
response: `{"object":"paywall_graph","id":"pw","version":"draft","graph":{"revision":3,"initial_step_id":"step_1","paywall_step_id":"step_1","total_steps":1,"steps":[{"id":"step_1","name":"Purchase","type":"screen","screen_types":["paywall"],"is_terminal":true,"paywall_id":"pw","edges":[],"unwired_triggers":[]}]}}`,
wantGraph: true,
wantSteps: 1,
},
{
name: "published, expanded",
fetch: func(client *api.Client) (*api.PaywallGraph, error) {
return client.Paywalls.GetGraphWithScreenContent(context.Background(), "proj", "pw", "published")
},
wantQuery: "version=published&expand=graph.steps.paywall",
response: `{"object":"paywall_graph","id":"pw","version":"published","graph":{"revision":3,"initial_step_id":"step_1","paywall_step_id":"step_1","total_steps":1,"steps":[{"id":"step_1","name":"Purchase","type":"screen","screen_types":["paywall"],"is_terminal":true,"paywall_id":"pw","edges":[],"unwired_triggers":[],"paywall":{"id":"pw","revision":5,"components_config":{},"components_localizations":{},"default_locale":"en_US","state_declarations":{}}}]}}`,
wantGraph: true,
wantSteps: 1,
wantScreen: true,
},
{
name: "standalone paywall has a null graph",
fetch: func(client *api.Client) (*api.PaywallGraph, error) {
return client.Paywalls.GetGraph(context.Background(), "proj", "pw", "draft")
},
wantQuery: "version=draft",
response: `{"object":"paywall_graph","id":"pw","version":"draft","graph":null}`,
wantGraph: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotPath, gotQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, tt.response)
}))
t.Cleanup(srv.Close)

client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL})
graph, err := tt.fetch(client)
if err != nil {
t.Fatal(err)
}
if gotPath != "/projects/proj/paywalls/pw/graph" {
t.Fatalf("path = %s", gotPath)
}
if gotQuery != tt.wantQuery {
t.Fatalf("query = %s, want %s", gotQuery, tt.wantQuery)
}
if (graph.Graph != nil) != tt.wantGraph {
t.Fatalf("graph = %+v, want present=%v", graph.Graph, tt.wantGraph)
}
if !tt.wantGraph {
return
}
if len(graph.Graph.Steps) != tt.wantSteps {
t.Fatalf("steps = %d, want %d", len(graph.Graph.Steps), tt.wantSteps)
}
if (graph.Graph.Steps[0].Paywall != nil) != tt.wantScreen {
t.Fatalf("step content present = %v, want %v", graph.Graph.Steps[0].Paywall != nil, tt.wantScreen)
}
})
}
}

func TestPaywallsUpdateDraftStep(t *testing.T) {
var gotPath, gotQuery string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
// The response id is the sibling's own canonical id, deliberately
// different from the parent id in the path.
_, _ = io.WriteString(w, `{"id":"pw_sibling","created_at":1,"published_at":null,"object":"paywall"}`)
}))
t.Cleanup(srv.Close)

client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL})
name := "should never be sent for a sibling"
updated, err := client.Paywalls.UpdateDraftStep(context.Background(), "proj", "pw_parent", "step_2", api.PaywallDraftUpdate{
Revision: 5,
ComponentsConfig: json.RawMessage(`{}`),
ComponentsLocalizations: json.RawMessage(`{}`),
DefaultLocale: "en_US",
Name: &name,
})
if err != nil {
t.Fatal(err)
}
if gotPath != "/projects/proj/paywalls/pw_parent" {
t.Fatalf("path = %s, want the parent's path, not the sibling's", gotPath)
}
if gotQuery != "step_id=step_2" {
t.Fatalf("query = %s, want step_id=step_2", gotQuery)
}
if _, present := gotBody["name"]; present {
t.Fatalf("body = %v, must never send name in selected mode", gotBody)
}
if updated.ID != "pw_sibling" {
t.Fatalf("response id = %s, want pw_sibling (callers must read it from the response, not assume it equals the path id)", updated.ID)
}
}

func TestPaywallsUnpublishPreservesDraftState(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/projects/proj/paywalls/pw/actions/unpublish" {
Expand Down
66 changes: 66 additions & 0 deletions internal/cli/paywalls.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ URL, get the user's approval, then rc paywalls publish.`,
cmd.AddCommand(
newPaywallsListCmd(),
newPaywallsShowCmd(),
newPaywallsScreensCmd(),
newPaywallsGenerateCmd(),
newPaywallsEditCmd(),
newPaywallsRewindCmd(),
Expand Down Expand Up @@ -413,6 +414,71 @@ builder URL instead — show returns metadata, not visuals.`,
}
}

// newPaywallsScreensCmd lists a paywall's graph steps — the id shown here is
// what --step-id on rc paywalls edit expects. Purchase-screen status comes
// from the graph's paywall_step_id, never from a step's is_terminal (that
// only means "no outgoing edges").
func newPaywallsScreensCmd() *cobra.Command {
var version string
cmd := &cobra.Command{
Use: "screens [paywall-id]",
Short: "List a Paywall's screens",
Long: `Lists the screens in a paywall's graph: id, name, position, and which one is
the purchase screen. Pass a screen's id to rc paywalls edit --step-id to edit
it instead of the default fallback screen.

A standalone paywall (one with no screen graph) has no screens to list — it
is edited as a single screen with rc paywalls edit.`,
Example: ` rc paywalls screens pw_abc
rc paywalls screens pw_abc --version published --json`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
rt := RuntimeFrom(cmd.Context())
projectID, err := requireProject(rt)
if err != nil {
return err
}
client, err := rt.API()
if err != nil {
return err
}
paywallID, err := requireID(rt, argAt(args, 0), "paywall", func() ([]PickerItem, error) {
return paywallPickerItems(cmd.Context(), client, projectID)
})
if err != nil {
return err
}
graph, err := client.Paywalls.GetGraph(cmd.Context(), projectID, paywallID, version)
if err != nil {
return err
}
if graph.Graph == nil {
rt.Out.Info(fmt.Sprintf("Paywall %s is standalone — it has no screen graph; rc paywalls edit always edits its one screen.", paywallID))
return rt.Out.Render(graph)
}
rows := make([][]string, len(graph.Graph.Steps))
for i, step := range graph.Graph.Steps {
name := "—"
if step.Name != nil && *step.Name != "" {
name = *step.Name
}
purchase := "no"
if step.ID == graph.Graph.PaywallStepID {
purchase = "yes"
}
rows[i] = []string{fmt.Sprintf("%d", i+1), name, step.ID, purchase, step.Type}
}
return rt.Out.RenderTable(output.Table{
Columns: []string{"POSITION", "NAME", "ID", "PURCHASE SCREEN", "TYPE"},
Rows: rows,
Raw: graph,
})
},
}
cmd.Flags().StringVar(&version, "version", "draft", "graph version to inspect (draft or published)")
return cmd
}

func newPaywallsDeleteCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Expand Down
Loading
Loading