From 269b56b9003ea8af78c83a321982ab71176d4492 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 28 Oct 2026 18:03:22 -0500 Subject: [PATCH 1/8] feat(flows): add draft authoring validation and publishing --- docs/command-surface.md | 7 + docs/specs/cli-coverage.yaml | 16 + docs/specs/v2-beta-overlay.yaml | 1373 +++++++++++++++++++++++++- internal/api/flows.go | 112 +++ internal/api/flows_authoring_test.go | 101 ++ internal/api/paths_gen.go | 20 + internal/api/types_gen.go | 78 ++ internal/cli/experiment_config.go | 10 +- internal/cli/flows.go | 4 +- internal/cli/flows_authoring.go | 222 +++++ internal/cli/flows_authoring_test.go | 181 ++++ oapi-codegen.yaml | 15 + 12 files changed, 2128 insertions(+), 11 deletions(-) create mode 100644 internal/api/flows_authoring_test.go create mode 100644 internal/cli/flows_authoring.go create mode 100644 internal/cli/flows_authoring_test.go diff --git a/docs/command-surface.md b/docs/command-surface.md index 80274e23..313c48db 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -198,6 +198,13 @@ rc paywalls delete [--force] # attached/published pa # Flows (paywalls, web funnels, and multi-step experiences) rc flows list [--type paywall|web-funnel|general] [--exclude-type ...] # experimental (hidden from --help until Checkpoint rules roll out); repeatable; shows type and publish state +rc flows show # editable draft, including steps and screen content +rc flows export --output # save an update request containing the current revision and all steps; refuses to overwrite +rc flows update --config # save the exported JSON or a partial update; only deleted_step_ids removes steps +rc flows create --config # create an unpublished paywall, web-funnel, or general flow +rc flows duplicate [--name ] [--source-version draft|published] # create an unpublished copy +rc flows validate [--revision ] # return saved-draft validation issues +rc flows publish --revision # validate and publish that revision; confirmation/--yes # Experiments — development v2 endpoints, normal discoverable CLI surface rc experiments list [--status ] rc experiments show # human detail view shows variants, targeting, placements, and chronological timeline diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index 166b91bc..75333f8a 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -289,3 +289,19 @@ endpoints: path: /projects/{project_id}/store_state/plans - method: GET path: /projects/{project_id}/store_state/plans/{plan_id} + + # Flow authoring (development API) + - method: POST + path: /projects/{project_id}/flows + - method: GET + path: /projects/{project_id}/flows/{flow_id} + - method: POST + path: /projects/{project_id}/flows/{flow_id}/actions/duplicate + - method: POST + path: /projects/{project_id}/flows/{flow_id}/actions/publish + - method: POST + path: /projects/{project_id}/flows/{flow_id}/actions/validate + - method: GET + path: /projects/{project_id}/flows/{flow_id}/draft + - method: PATCH + path: /projects/{project_id}/flows/{flow_id}/draft diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index 62a8d760..a023c99f 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -920,22 +920,560 @@ paths: '200': description: OK /projects/{project_id}/flows: - parameters: - - name: project_id - in: path - required: true - schema: - type: string get: summary: Get a list of Flows operationId: list-flows + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: starting_after + in: query + required: false + description: Pagination cursor. Pass the id of the last item from the previous + page to fetch the next page; omit for the first page. + schema: + type: string + example: ent12354 + - name: limit + in: query + required: false + description: Maximum number of items to return per page. Values below 1 or + above 100 are clamped to that range rather than rejected. + schema: + type: integer + example: 10 + default: 20 + - name: type + in: query + description: Only return Flows of these types. `general` matches Flows without + a specific type. Cannot be combined with `exclude_type`. + required: false + schema: + type: array + items: + type: string + enum: + - paywall + - web-funnel + - general + example: paywall + - name: exclude_type + in: query + description: Return Flows of every type except these. `general` matches Flows + without a specific type. Cannot be combined with `type`. + required: false + schema: + type: array + items: + type: string + enum: + - paywall + - web-funnel + - general + example: paywall + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/ListFlows' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + post: + summary: Create a Flow draft + operationId: create-flow + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read_write x-release-status: development x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFlow' + responses: + '201': + description: The new unpublished Flow + content: + application/json: + schema: + $ref: '#/components/schemas/Flow' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '409': + description: Conflict + '422': + description: UnprocessableEntity + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + /projects/{project_id}/flows/{flow_id}: + get: + summary: Get a Flow + operationId: get-flow + x-revenuecat-rate-limiting-domain: project_configuration x-scopes: - project_configuration:workflows:read + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: flow_id + description: ID of the Flow + required: true + in: path + schema: + type: string + minLength: 1 + maxLength: 255 + example: wf1a2b3c4d5e6f7g8 responses: '200': - description: OK + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/Flow' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '404': + description: NotFound + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + /projects/{project_id}/flows/{flow_id}/actions/duplicate: + post: + summary: Duplicate a Flow + operationId: duplicate-flow + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read_write + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: flow_id + description: ID of the Flow + required: true + in: path + schema: + type: string + minLength: 1 + maxLength: 255 + example: wf1a2b3c4d5e6f7g8 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DuplicateFlow' + responses: + '201': + description: The new unpublished Flow + content: + application/json: + schema: + $ref: '#/components/schemas/Flow' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '409': + description: Conflict + '422': + description: UnprocessableEntity + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + /projects/{project_id}/flows/{flow_id}/actions/publish: + post: + summary: Publish a Flow draft + operationId: publish-flow + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read_write + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: flow_id + description: ID of the Flow + required: true + in: path + schema: + type: string + minLength: 1 + maxLength: 255 + example: wf1a2b3c4d5e6f7g8 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishFlow' + responses: + '200': + description: The published Flow + content: + application/json: + schema: + $ref: '#/components/schemas/Flow' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '409': + description: Conflict + '422': + description: UnprocessableEntity + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + /projects/{project_id}/flows/{flow_id}/actions/validate: + post: + summary: Validate a Flow draft + operationId: validate-flow + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: flow_id + description: ID of the Flow + required: true + in: path + schema: + type: string + minLength: 1 + maxLength: 255 + example: wf1a2b3c4d5e6f7g8 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateFlow' + responses: + '200': + description: Draft validation issues + content: + application/json: + schema: + $ref: '#/components/schemas/FlowValidation' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '409': + description: Conflict + '422': + description: UnprocessableEntity + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + /projects/{project_id}/flows/{flow_id}/draft: + get: + summary: Get a Flow draft + operationId: get-flow-draft + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: flow_id + description: ID of the Flow + required: true + in: path + schema: + type: string + minLength: 1 + maxLength: 255 + example: wf1a2b3c4d5e6f7g8 + responses: + '200': + description: The editable Flow draft + content: + application/json: + schema: + $ref: '#/components/schemas/FlowDraft' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' + patch: + summary: Update a Flow draft + operationId: update-flow-draft + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:workflows:read_write + x-release-status: development + x-revenuecat-release-gatekeeping: false + tags: + - Flow + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + - name: flow_id + description: ID of the Flow + required: true + in: path + schema: + type: string + minLength: 1 + maxLength: 255 + example: wf1a2b3c4d5e6f7g8 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFlowDraft' + responses: + '200': + description: The Flow with its new revision + content: + application/json: + schema: + $ref: '#/components/schemas/Flow' + headers: + RevenueCat-Rate-Limit-Current-Usage: + $ref: '#/components/headers/RateLimitCurrentUsage' + RevenueCat-Rate-Limit-Current-Limit: + $ref: '#/components/headers/RateLimitCurrentLimit' + '409': + description: Conflict + '400': + description: BadRequest + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: NotFound + '422': + description: UnprocessableEntity + '423': + description: Locked + '429': + description: RateLimited + '500': + description: InternalError + '503': + description: InternalError + description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. + This endpoint belongs to the Project Configuration domain, + which has a default rate limit of 60 requests per minute.' /projects/{project_id}/checkpoints: parameters: - name: project_id @@ -1416,3 +1954,824 @@ components: - ZAR - ZMW - ZWL + ListFlows: + type: object + required: + - items + - next_page + - object + - url + title: FlowList + properties: + object: + description: String representing the object's type. Objects of the same type + share the same value. Always has the value `list`. + enum: + - list + type: string + items: + description: Details about each object. + items: + $ref: '#/components/schemas/Flow' + type: array + next_page: + description: URL to access the next page of the project's Flows. If not present + / null, there is no next page + type: string + nullable: true + example: /v2/projects/proj1ab2c3d4/flows?starting_after=wrkflw1a2b3c4d5 + url: + description: The URL where this list can be accessed. + maxLength: 5000 + type: string + example: /v2/projects/proj1ab2c3d4/flows + additionalProperties: false + Flow: + type: object + required: + - object + - id + - name + - created_at + - project_id + - type + - published_at + - has_unpublished_changes + - revision + properties: + object: + description: String representing the object's type. Objects of the same type + share the same value. + enum: + - flow + type: string + id: + description: The id of the Flow + nullable: false + type: string + minLength: 1 + maxLength: 255 + example: wf_1a2b3c4d5 + name: + description: The display name of the Flow + nullable: false + type: string + minLength: 1 + maxLength: 1500 + example: My offering flow + created_at: + description: The date the Flow was created at in ms since epoch + nullable: false + type: integer + format: int64 + example: 1658399423658 + project_id: + description: ID of the project to which the Flow belongs + nullable: false + minLength: 1 + maxLength: 20 + type: string + example: proj1ab2c3d4 + type: + description: The type of the Flow. `general` means it has no specific type. + nullable: false + type: string + enum: + - paywall + - web-funnel + - general + example: paywall + published_at: + description: The date the Flow was last published in ms since epoch, or null + if it has never been published + nullable: true + type: integer + format: int64 + example: 1658399423658 + has_unpublished_changes: + description: Whether the Flow has draft changes that are not published yet. + Always true for a Flow that has never been published. + nullable: false + type: boolean + example: false + revision: + type: integer + format: int64 + description: Current draft revision. + example: 1 + additionalProperties: false + CreateFlow: + type: object + required: + - display_name + additionalProperties: false + properties: + type: + type: string + description: Flow type. + example: web-funnel + enum: + - paywall + - web-funnel + - general + default: general + display_name: + type: string + description: Display name of the flow. + example: Onboarding + minLength: 1 + maxLength: 1500 + initial_step_id: + type: string + description: ID of the first step. + example: welcome + nullable: true + maxLength: 255 + single_step_fallback_id: + type: string + description: Fallback screen for clients that cannot present multiple steps. + example: paywall + nullable: true + maxLength: 255 + app_id: + type: string + description: Web billing app ID. + example: app123456789a + nullable: true + maxLength: 200 + metadata: + type: object + description: Flow metadata, including web funnel settings. + example: {} + additionalProperties: true + nullable: true + custom_context_parameters_config: + type: object + description: Custom context parameters. + example: {} + additionalProperties: true + content_max_width: + type: integer + description: Maximum web content width. + example: 600 + nullable: true + minimum: 1 + steps: + type: object + description: Editable draft steps keyed by stable step ID. + example: {} + additionalProperties: + $ref: '#/components/schemas/FlowStep' + maxProperties: 1000 + FlowStep: + type: object + properties: + name: + type: string + description: Display name of the step. + example: Welcome + nullable: true + type: + type: string + description: Required when creating a step. + example: screen + enum: + - screen + - checkout + - authentication + - branch + - experiment + - offering + - ad + param_values: + type: object + description: Step-specific configuration. + example: {} + additionalProperties: true + trigger_actions: + type: object + description: Navigation and actions keyed by trigger. + example: {} + additionalProperties: true + triggers: + type: array + description: Step triggers. + example: [] + items: {} + outputs: + type: object + description: Output definitions keyed by component ID. + example: {} + additionalProperties: true + paywall_rc_public_id: + type: string + description: Existing workflow screen in this project. Mutually exclusive with + paywall. + example: pw123456789a + nullable: true + paywall: + $ref: '#/components/schemas/FlowScreen' + description: Inline screen content. Explicit null removes an authentication + step draft-only owned screen. + nullable: true + rc_metadata: + type: object + description: Step metadata. + example: {} + additionalProperties: true + nullable: true + node_position: + type: object + required: + - x + - y + properties: + x: + type: number + description: Canvas x coordinate. + example: 0 + y: + type: number + description: Canvas y coordinate. + example: 0 + additionalProperties: false + nullable: true + screen_type: + type: array + description: Screen classifications. + example: + - initial + items: + type: string + enum: + - paywall + - checkout + - success + - initial + - second + - authentication + - experiment + - redirect + uniqueItems: true + additionalProperties: false + FlowScreen: + type: object + properties: + default_locale: + type: string + description: Default locale of the screen. + example: en_US + nullable: true + components_config: + type: object + description: Raw components configuration, in the same format as paywall drafts. + example: {} + additionalProperties: true + components_localizations: + type: object + description: Component values keyed by locale. + example: + en_US: {} + additionalProperties: + type: object + additionalProperties: true + metadata: + type: object + description: Screen metadata. + example: {} + additionalProperties: true + nullable: true + params: + type: array + description: Screen parameters. + example: [] + items: + type: object + additionalProperties: true + offering_id: + type: string + description: Offering ID for a paywall fallback screen. An empty string detaches + the offering. + example: ofrng123456789a + nullable: true + exit_offers: + type: object + description: Exit offers. Only used by paywall-type flows. + example: {} + additionalProperties: true + nullable: true + play_store_product_change_mode: + type: object + properties: + upgrade_replacement_mode: + type: string + description: Play Store replacement mode. + example: deferred + enum: + - charge_prorated_price + - charge_full_price + - with_time_proration + - without_proration + - deferred + nullable: true + downgrade_replacement_mode: + type: string + description: Play Store replacement mode. + example: deferred + enum: + - charge_full_price + - with_time_proration + - without_proration + - deferred + nullable: true + additionalProperties: false + nullable: true + automatically_scale_font_size: + type: boolean + description: Whether to scale font sizes automatically. + example: true + haptic_feedback_enabled: + type: boolean + description: Whether selections trigger haptic feedback. + example: false + state_declarations: + type: object + description: Per-screen state declarations. + example: {} + additionalProperties: true + nullable: true + generated_by: + type: string + description: Origin of the screen. + example: figma + nullable: true + additionalProperties: false + DuplicateFlow: + type: object + additionalProperties: false + properties: + display_name: + type: string + description: Name of the duplicate. + minLength: 1 + maxLength: 1500 + example: Onboarding Copy + source_version: + type: string + enum: + - draft + - published + default: draft + description: Which source version to copy. The copy is always unpublished. + example: draft + PublishFlow: + type: object + additionalProperties: false + required: + - revision + properties: + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + ValidateFlow: + type: object + additionalProperties: false + properties: + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + FlowValidation: + type: object + additionalProperties: true + required: + - object + - id + - valid + - revision + - step_issues + - workflow_errors + properties: + object: + type: string + enum: + - flow_validation + description: Type of this object. + example: flow_validation + id: + type: string + description: Flow ID. + example: wf_123456789a + valid: + type: boolean + description: Whether the draft can be published. Warnings do not prevent publication. + example: false + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + nullable: true + step_issues: + type: object + description: Issues keyed by step ID. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/FlowValidationIssue' + example: {} + workflow_errors: + type: array + description: Issues affecting the entire flow. + items: + $ref: '#/components/schemas/FlowValidationError' + example: [] + FlowValidationIssue: + type: object + additionalProperties: true + required: + - key + - message + - severity + properties: + key: + oneOf: + - $ref: '#/components/schemas/PaywallValidationIssueKey' + - $ref: '#/components/schemas/FlowValidationIssueKey' + message: + description: A human-readable description of the issue. + type: string + example: Add a name to your paywall. + severity: + description: The severity of the validation issue. + type: string + enum: + - error + - warning + example: error + component_id: + description: The component associated with the issue. + type: string + nullable: true + example: main_stack + property: + description: The component property associated with the issue. + type: string + nullable: true + example: font_size + locale: + description: The locale associated with the issue. + type: string + nullable: true + example: en_US + localization_id: + description: The localization identifier associated with the issue. + type: string + nullable: true + example: title + path: + description: The schema path associated with the issue. + type: string + nullable: true + example: paywall.components_config.base.stack + PaywallValidationIssueKey: + description: A stable identifier for a paywall validation issue. + type: string + enum: + - Schema_Invalid + - Paywall_NoOffering + - Paywall_NoPackage + - Paywall_NoName + - Paywall_NoDefaultPackage + - Paywall_NoPurchaseFlow + - Text_NoDefaultText + - Text_NoIntroText + - Text_NoMultipleIntroText + - Text_NoPromoText + - Text_NoFont + - Text_DefaultModifier + - Text_IntroModifier + - Text_MultipleIntroModifier + - Text_PromoModifier + - Text_NoDefaultLocalization + - Text_NoIntroLocalization + - Text_NoMultipleIntroLocalization + - Text_NoPromoLocalization + - Text_UndefinedVariable + - Text_UndefinedInputVariable + - Text_UndefinedIntroVariable + - Text_UndefinedMultipleIntroVariable + - Text_UndefinedPromoVariable + - Text_OfferVariableRequiresRule + - Image_NoSource + - Image_NoLightSource + - Video_NoSource + - Video_NoLightSource + - Video_NoLowRes + - Video_UrlWhitespace + - Video_UrlLowResWhitespace + - Video_DarkUrlWhitespace + - Video_DarkUrlLowResWhitespace + - Package_NoPackageId + - Countdown_NoDate + - Countdown_PastTargetDate + - Button_NoDestination + - Button_NoUrl + - Button_InvalidUrl + - Button_NoUrlLocalization + - Button_SampleUrl + - Button_WPLNoOffering + - PurchaseButton_NoUrl + - PurchaseButton_SampleUrl + - PurchaseButton_WPLNoOffering + - PurchaseButton_HostedCheckoutNoOffering + - PurchaseButton_HostedCheckoutNoWebProduct + - Icon_InvalidBaseUrl + - Icon_NoBackgroundColor + - Stack_BadgeText + - Stack_BadgeIntroOfferText + - Stack_BadgeMultipleIntroOffersText + - Stack_BadgeNoLocalization + - Stack_IntegerMargin + - Stack_IntegerPadding + - Stack_NegativeMargin + - Stack_NegativePadding + - Stack_IntegerCorners + - Stack_IntegerBorderWidth + - Tabs_ToggleRequiresTwoTabs + - Tab_OrphanedComponent + - State_UnreachableCondition + - Choice_NoId + - Choice_InvalidFieldId + - Choice_DuplicateId + - ChoiceOption_NoId + - ChoiceOption_DuplicateId + - InputText_NoId + - InputText_InvalidFieldId + - InputText_DuplicateFieldId + - InputText_ValidationRange + - InputText_ValidationPattern + - WebView_NoUrl + - WebView_InvalidUrl + - WebView_DefaultBundle + - ExpressPurchaseButton_TooManyInstances + - ExpressPurchaseButton_NoWalletButton + - Toggle_FreeTrialWarning + - Localization_Missing + example: Paywall_NoName + FlowValidationIssueKey: + type: string + description: Workflow step validation issue key. + enum: + - Step_Unreachable + - Step_InitialStepNotRoot + - Step_InvalidTriggerAction + - Step_InvalidCheckoutConnection + - Branch_NoStepSelected + - Checkout_NoPaywall + - Checkout_NoAppConfig + - Screen_NoOffering + - Screen_NoOfferingWebProducts + - Screen_NoRedemptionButton + - Screen_RedemptionButtonOnProtectedStep + - Screen_RedemptionButtonWithoutRedemptionPolicy + - Screen_RedemptionButtonOnMixedAuthPath + - Screen_PurchaseComponentOnNonFinalScreen + - Screen_PurchaseButtonWithoutCheckout + - Screen_PaywallStepHasOutgoingNavigation + - Screen_PaywallWorkflowMultipleOutgoing + - Screen_PaywallWorkflowMultipleIncoming + - Screen_PaywallWorkflowLeafNotPaywall + - Screen_FlowFinalScreenNoExit + - Authentication_NoExternalUrl + - Authentication_NotHttps + - Authentication_NoNextStep + - Authentication_NoAppConfig + - Authentication_NoIamMethod + - Authentication_IamMethodNotFound + - Authentication_IamMethodsUnavailable + - Authentication_NoIamMethodUrl + - Authentication_NoEmailInput + - Authentication_NoPasswordInput + - Experiment_VariantNoSteps + - Experiment_FallbackRequired + - Experiment_NeedsMoreVariants + - Experiment_NestedInVariant + - Experiment_PackageRulesBeforeOfferingSplit + - Workflow_UnsupportedType + example: Step_Unreachable + FlowValidationError: + type: object + additionalProperties: true + required: + - key + - message + - severity + properties: + key: + type: string + description: Workflow validation error key. + enum: + - Workflow_ExperimentRunningCombosOverLimit + - Workflow_ExperimentPotentialCombosOverLimit + - Workflow_NoPaywallScreen + - Workflow_RestoreButtonNotOnFinalScreen + - Workflow_NoInitialStep + - Workflow_InitialStepNotFound + - Workflow_NoCheckoutStep + - Workflow_CheckoutNotReachable + - Workflow_CheckoutNoSuccessStep + - Workflow_NoWebConfig + - Workflow_NoWebProducts + - Workflow_ExclusiveRuleMixedOutcome + - Workflow_CookieConsentNoPrivacyPolicy + example: Workflow_ExperimentRunningCombosOverLimit + message: + description: A human-readable description of the issue. + type: string + example: Add a name to your paywall. + severity: + description: The severity of the validation issue. + type: string + enum: + - error + - warning + example: error + action: + type: string + description: Suggested action to resolve the issue. + nullable: true + example: add_screen + FlowDraft: + type: object + required: + - object + - id + - type + - revision + - display_name + - initial_step_id + - single_step_fallback_id + - app_id + - metadata + - custom_context_parameters_config + - content_max_width + - steps + properties: + object: + type: string + description: Type of this object. + example: flow_draft + enum: + - flow_draft + id: + type: string + description: Flow ID. + example: wf_123456789a + type: + type: string + description: Flow type. + example: web-funnel + enum: + - paywall + - web-funnel + - general + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + display_name: + type: string + description: Display name of the flow. + example: Onboarding + minLength: 1 + maxLength: 1500 + initial_step_id: + type: string + description: ID of the first step. + example: welcome + nullable: true + maxLength: 255 + single_step_fallback_id: + type: string + description: Fallback screen for clients that cannot present multiple steps. + example: paywall + nullable: true + maxLength: 255 + app_id: + type: string + description: Web billing app ID. + example: app123456789a + nullable: true + maxLength: 200 + metadata: + type: object + description: Flow metadata, including web funnel settings. + example: {} + additionalProperties: true + nullable: true + custom_context_parameters_config: + type: object + description: Custom context parameters. + example: {} + additionalProperties: true + content_max_width: + type: integer + description: Maximum web content width. + example: 600 + nullable: true + minimum: 1 + steps: + type: object + description: Editable draft steps keyed by stable step ID. + example: {} + additionalProperties: + $ref: '#/components/schemas/FlowStep' + maxProperties: 1000 + additionalProperties: true + UpdateFlowDraft: + type: object + additionalProperties: false + required: + - revision + properties: + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + display_name: + type: string + description: Display name of the flow. + example: Onboarding + minLength: 1 + maxLength: 1500 + initial_step_id: + type: string + description: ID of the first step. + example: welcome + nullable: true + maxLength: 255 + single_step_fallback_id: + type: string + description: Fallback screen for clients that cannot present multiple steps. + example: paywall + nullable: true + maxLength: 255 + app_id: + type: string + description: Web billing app ID. + example: app123456789a + nullable: true + maxLength: 200 + metadata: + type: object + description: Flow metadata, including web funnel settings. + example: {} + additionalProperties: true + nullable: true + custom_context_parameters_config: + type: object + description: Custom context parameters. + example: {} + additionalProperties: true + content_max_width: + type: integer + description: Maximum web content width. + example: 600 + nullable: true + minimum: 1 + updated_steps: + type: object + description: Steps to create or update. Omitted steps are preserved. Step fields + are patches; omitted or null fields preserve stored values, except explicit + paywall=null on an authentication step. + example: {} + additionalProperties: + $ref: '#/components/schemas/FlowStep' + maxProperties: 1000 + deleted_step_ids: + type: array + description: Draft steps to delete explicitly. A step cannot also appear in + updated_steps. + items: + type: string + minLength: 1 + maxLength: 255 + maxItems: 1000 + uniqueItems: true + example: [] diff --git a/internal/api/flows.go b/internal/api/flows.go index a21776ba..a49fe254 100644 --- a/internal/api/flows.go +++ b/internal/api/flows.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "net/http" "net/url" "strconv" @@ -19,6 +20,73 @@ type Flow struct { CreatedAt Millis `json:"created_at,omitempty"` PublishedAt *Millis `json:"published_at"` HasUnpublishedChanges bool `json:"has_unpublished_changes"` + Revision int `json:"revision"` +} + +type FlowSettings struct { + DisplayName *string `json:"display_name,omitempty"` + InitialStepID json.RawMessage `json:"initial_step_id,omitempty"` + SingleStepFallbackID json.RawMessage `json:"single_step_fallback_id,omitempty"` + AppID json.RawMessage `json:"app_id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CustomContextParametersConfig json.RawMessage `json:"custom_context_parameters_config,omitempty"` + ContentMaxWidth json.RawMessage `json:"content_max_width,omitempty"` +} + +type FlowDraft struct { + FlowSettings + ID string `json:"id"` + Object string `json:"object"` + Type string `json:"type"` + Revision int `json:"revision"` + Steps map[string]json.RawMessage `json:"steps"` +} + +type UpdateFlowDraft struct { + FlowSettings + Revision int `json:"revision"` + UpdatedSteps map[string]json.RawMessage `json:"updated_steps,omitempty"` + DeletedStepIDs []string `json:"deleted_step_ids,omitempty"` +} + +type CreateFlow struct { + FlowSettings + Type string `json:"type,omitempty"` + Steps map[string]json.RawMessage `json:"steps,omitempty"` +} + +type DuplicateFlow struct { + DisplayName string `json:"display_name,omitempty"` + SourceVersion string `json:"source_version,omitempty"` +} + +type ValidateFlow struct { + Revision int `json:"revision,omitempty"` +} + +type PublishFlow struct { + Revision int `json:"revision"` +} + +type FlowValidationIssue struct { + Key string `json:"key"` + Message string `json:"message"` + Severity string `json:"severity"` + ComponentID *string `json:"component_id,omitempty"` + Property *string `json:"property,omitempty"` + Locale *string `json:"locale,omitempty"` + LocalizationID *string `json:"localization_id,omitempty"` + Path *string `json:"path,omitempty"` + Action *string `json:"action,omitempty"` +} + +type FlowValidation struct { + ID string `json:"id"` + Object string `json:"object"` + Revision *int `json:"revision"` + Valid bool `json:"valid"` + StepIssues map[string][]FlowValidationIssue `json:"step_issues"` + WorkflowErrors []FlowValidationIssue `json:"workflow_errors"` } // FlowListOptions filters by Flow type. Type and ExcludeType can't be combined. @@ -55,3 +123,47 @@ func (s *FlowsService) List(ctx context.Context, projectID string, opts FlowList } return &out, nil } + +func (s *FlowsService) Get(ctx context.Context, projectID, id string) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodGet, pathFlow(projectID, id), nil, &out) + return &out, err +} + +func (s *FlowsService) GetDraft(ctx context.Context, projectID, id string) (*FlowDraft, error) { + var out FlowDraft + err := s.c.do(ctx, http.MethodGet, pathFlowDraft(projectID, id), nil, &out) + return &out, err +} + +func (s *FlowsService) UpdateDraft(ctx context.Context, projectID, id string, body UpdateFlowDraft) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodPatch, pathFlowDraft(projectID, id), body, &out) + return &out, err +} + +func (s *FlowsService) Create(ctx context.Context, projectID string, body CreateFlow) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodPost, pathFlows(projectID), body, &out) + return &out, err +} + +func (s *FlowsService) Duplicate(ctx context.Context, projectID, id string, body DuplicateFlow) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodPost, pathFlowActionsDuplicate(projectID, id), body, &out) + return &out, err +} + +func (s *FlowsService) Validate(ctx context.Context, projectID, id string, revision int) (*FlowValidation, error) { + var out FlowValidation + body := ValidateFlow{Revision: revision} + err := s.c.do(ctx, http.MethodPost, pathFlowActionsValidate(projectID, id), body, &out) + return &out, err +} + +func (s *FlowsService) Publish(ctx context.Context, projectID, id string, revision int) (*Flow, error) { + var out Flow + body := PublishFlow{Revision: revision} + err := s.c.do(ctx, http.MethodPost, pathFlowActionsPublish(projectID, id), body, &out) + return &out, err +} diff --git a/internal/api/flows_authoring_test.go b/internal/api/flows_authoring_test.go new file mode 100644 index 00000000..9a82b476 --- /dev/null +++ b/internal/api/flows_authoring_test.go @@ -0,0 +1,101 @@ +package api_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/revenuecat/cli/internal/api" +) + +func TestFlowAuthoringRoutes(t *testing.T) { + for _, tc := range []struct { + name, method, suffix, body string + call func(*api.Client) error + }{ + {"get", "GET", "", "", func(c *api.Client) error { _, err := c.Flows.Get(context.Background(), "proj", "wf_1"); return err }}, + {"draft", "GET", "/draft", "", func(c *api.Client) error { + _, err := c.Flows.GetDraft(context.Background(), "proj", "wf_1") + return err + }}, + {"update", "PATCH", "/draft", `{"revision":3,"metadata":null,"deleted_step_ids":["old"]}`, func(c *api.Client) error { + _, err := c.Flows.UpdateDraft(context.Background(), "proj", "wf_1", api.UpdateFlowDraft{FlowSettings: api.FlowSettings{Metadata: json.RawMessage("null")}, Revision: 3, DeletedStepIDs: []string{"old"}}) + return err + }}, + {"duplicate", "POST", "/actions/duplicate", `{"source_version":"published"}`, func(c *api.Client) error { + _, err := c.Flows.Duplicate(context.Background(), "proj", "wf_1", api.DuplicateFlow{SourceVersion: "published"}) + return err + }}, + {"validate latest", "POST", "/actions/validate", `{}`, func(c *api.Client) error { + _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", 0) + return err + }}, + {"validate revision", "POST", "/actions/validate", `{"revision":3}`, func(c *api.Client) error { + _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", 3) + return err + }}, + {"publish", "POST", "/actions/publish", `{"revision":3}`, func(c *api.Client) error { + _, err := c.Flows.Publish(context.Background(), "proj", "wf_1", 3) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != tc.method || r.URL.Path != "/projects/proj/flows/wf_1"+tc.suffix { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if tc.body == "" { + if len(body) != 0 { + t.Errorf("unexpected body %s", body) + } + } else { + var got, want any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + _ = json.Unmarshal([]byte(tc.body), &want) + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("body = %s, want %s", gotJSON, wantJSON) + } + } + _, _ = io.WriteString(w, `{"id":"wf_1","revision":3,"valid":true}`) + })) + defer server.Close() + if err := tc.call(api.NewClient(api.Options{APIKey: "sk", BaseURL: server.URL})); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestFlowUpdatePreservesRawScreenJSONAndConflict(t *testing.T) { + const raw = `{"type":"screen","paywall":{"components_config":{"large":9007199254740993},"components_localizations":{"en_US":{"title":"Hello"}},"haptics_enabled":false}}` + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), raw) { + t.Errorf("screen changed: %s", body) + } + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"type":"resource_conflict","message":"Draft revision changed"}`) + })) + defer server.Close() + client := api.NewClient(api.Options{APIKey: "sk", BaseURL: server.URL}) + _, err := client.Flows.UpdateDraft(context.Background(), "proj", "wf_1", api.UpdateFlowDraft{Revision: 2, UpdatedSteps: map[string]json.RawMessage{"screen": json.RawMessage(raw)}}) + var apiError *api.APIError + if !errors.As(err, &apiError) || apiError.Status != http.StatusConflict { + t.Fatalf("error = %v", err) + } + if requests != 1 { + t.Errorf("conflicting update retried %d times", requests) + } +} diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index ab8439b0..60aa5953 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -162,6 +162,26 @@ func pathExperiments(projectID string) string { return encodePath("projects", projectID, "experiments") } +func pathFlow(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID) +} + +func pathFlowActionsDuplicate(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID, "actions", "duplicate") +} + +func pathFlowActionsPublish(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID, "actions", "publish") +} + +func pathFlowActionsValidate(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID, "actions", "validate") +} + +func pathFlowDraft(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID, "draft") +} + func pathFlows(projectID string) string { return encodePath("projects", projectID, "flows") } diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 0ec9f15e..08976400 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -17152,6 +17152,48 @@ func (e GetProductsFromEntitlement503JSONResponseBodyType) Valid() bool { } } +// Defines values for ListFlowsParamsType. +const ( + ListFlowsParamsTypeGeneral ListFlowsParamsType = "general" + ListFlowsParamsTypePaywall ListFlowsParamsType = "paywall" + ListFlowsParamsTypeWebFunnel ListFlowsParamsType = "web-funnel" +) + +// Valid indicates whether the value is a known member of the ListFlowsParamsType enum. +func (e ListFlowsParamsType) Valid() bool { + switch e { + case ListFlowsParamsTypeGeneral: + return true + case ListFlowsParamsTypePaywall: + return true + case ListFlowsParamsTypeWebFunnel: + return true + default: + return false + } +} + +// Defines values for ListFlowsParamsExcludeType. +const ( + ListFlowsParamsExcludeTypeGeneral ListFlowsParamsExcludeType = "general" + ListFlowsParamsExcludeTypePaywall ListFlowsParamsExcludeType = "paywall" + ListFlowsParamsExcludeTypeWebFunnel ListFlowsParamsExcludeType = "web-funnel" +) + +// Valid indicates whether the value is a known member of the ListFlowsParamsExcludeType enum. +func (e ListFlowsParamsExcludeType) Valid() bool { + switch e { + case ListFlowsParamsExcludeTypeGeneral: + return true + case ListFlowsParamsExcludeTypePaywall: + return true + case ListFlowsParamsExcludeTypeWebFunnel: + return true + default: + return false + } +} + // Defines values for ListWebhookIntegrations400JSONResponseBodyObject. const ( ListWebhookIntegrations400JSONResponseBodyObjectError ListWebhookIntegrations400JSONResponseBodyObject = "error" @@ -41596,6 +41638,27 @@ type GetProductsFromEntitlement503JSONResponseBodyObject string // GetProductsFromEntitlement503JSONResponseBodyType defines parameters for GetProductsFromEntitlement. type GetProductsFromEntitlement503JSONResponseBodyType string +// ListFlowsParams defines parameters for ListFlows. +type ListFlowsParams struct { + // StartingAfter Pagination cursor. Pass the id of the last item from the previous page to fetch the next page; omit for the first page. + StartingAfter *string `form:"starting_after,omitempty" json:"starting_after,omitempty"` + + // Limit Maximum number of items to return per page. Values below 1 or above 100 are clamped to that range rather than rejected. + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` + + // Type Only return Flows of these types. `general` matches Flows without a specific type. Cannot be combined with `exclude_type`. + Type *[]ListFlowsParamsType `form:"type,omitempty" json:"type,omitempty"` + + // ExcludeType Return Flows of every type except these. `general` matches Flows without a specific type. Cannot be combined with `type`. + ExcludeType *[]ListFlowsParamsExcludeType `form:"exclude_type,omitempty" json:"exclude_type,omitempty"` +} + +// ListFlowsParamsType defines parameters for ListFlows. +type ListFlowsParamsType string + +// ListFlowsParamsExcludeType defines parameters for ListFlows. +type ListFlowsParamsExcludeType string + // ListWebhookIntegrationsParams defines parameters for ListWebhookIntegrations. type ListWebhookIntegrationsParams struct { // StartingAfter Pagination cursor. Pass the id of the last item from the previous page to fetch the next page; omit for the first page. @@ -45432,6 +45495,21 @@ type AttachProductsToEntitlementJSONRequestBody AttachProductsToEntitlementJSONB // DetachProductsFromEntitlementJSONRequestBody defines body for DetachProductsFromEntitlement for application/json ContentType. type DetachProductsFromEntitlementJSONRequestBody DetachProductsFromEntitlementJSONBody +// CreateFlowJSONRequestBody defines body for CreateFlow for application/json ContentType. +type CreateFlowJSONRequestBody = CreateFlow + +// DuplicateFlowJSONRequestBody defines body for DuplicateFlow for application/json ContentType. +type DuplicateFlowJSONRequestBody = DuplicateFlow + +// PublishFlowJSONRequestBody defines body for PublishFlow for application/json ContentType. +type PublishFlowJSONRequestBody = PublishFlow + +// ValidateFlowJSONRequestBody defines body for ValidateFlow for application/json ContentType. +type ValidateFlowJSONRequestBody = ValidateFlow + +// UpdateFlowDraftJSONRequestBody defines body for UpdateFlowDraft for application/json ContentType. +type UpdateFlowDraftJSONRequestBody = UpdateFlowDraft + // CreateWebhookIntegrationJSONRequestBody defines body for CreateWebhookIntegration for application/json ContentType. type CreateWebhookIntegrationJSONRequestBody = CreateWebhookIntegrationInput diff --git a/internal/cli/experiment_config.go b/internal/cli/experiment_config.go index b9e7d3c5..c495f9e8 100644 --- a/internal/cli/experiment_config.go +++ b/internal/cli/experiment_config.go @@ -11,6 +11,10 @@ import ( const maxExperimentConfigBytes = 1 << 20 func readJSONConfig(path string, out any) error { + return readJSONConfigWithLimit(path, out, maxExperimentConfigBytes) +} + +func readJSONConfigWithLimit(path string, out any, maxBytes int64) error { var reader io.Reader if path == "-" { reader = os.Stdin @@ -22,12 +26,12 @@ func readJSONConfig(path string, out any) error { defer file.Close() reader = file } - data, err := io.ReadAll(io.LimitReader(reader, maxExperimentConfigBytes+1)) + data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1)) if err != nil { return err } - if len(data) > maxExperimentConfigBytes { - return fmt.Errorf("config exceeds 1 MiB") + if int64(len(data)) > maxBytes { + return fmt.Errorf("config exceeds %d MiB", maxBytes>>20) } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() diff --git a/internal/cli/flows.go b/internal/cli/flows.go index 01de574b..e5f125ed 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -13,13 +13,15 @@ func newFlowsCmd() *cobra.Command { Aliases: []string{"flow"}, // Hidden from --help until Checkpoint rules roll out. Annotations: map[string]string{annotationSurface: surfaceExperimental}, - Short: "View Flows (paywalls, web funnels, and multi-step experiences)", + Short: "Manage Flows (paywalls, web funnels, and multi-step experiences)", Long: `A Flow is a paywall, web funnel, or other multi-step experience built in the RevenueCat dashboard. Checkpoint rules decide which Flow a customer sees.`, Example: ` rc flows list rc flows list --type paywall`, } cmd.AddCommand(newFlowsListCmd()) + cmd.AddCommand(newFlowsShowCmd(), newFlowsExportCmd(), newFlowsUpdateCmd(), + newFlowsCreateCmd(), newFlowsDuplicateCmd(), newFlowsValidateCmd(), newFlowsPublishCmd()) return cmd } diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go new file mode 100644 index 00000000..0a0d1c07 --- /dev/null +++ b/internal/cli/flows_authoring.go @@ -0,0 +1,222 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/revenuecat/cli/internal/api" +) + +const maxFlowConfigBytes = 5 << 20 + +func flowClient(cmd *cobra.Command) (*Runtime, *api.Client, string, error) { + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return nil, nil, "", err + } + client, err := rt.API() + return rt, client, projectID, err +} + +func newFlowsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show ", Short: "Show a Flow's editable draft", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + draft, err := client.Flows.GetDraft(cmd.Context(), projectID, args[0]) + if err != nil { + return err + } + return rt.Out.Render(draft) + }, + } +} + +func newFlowsExportCmd() *cobra.Command { + var path string + cmd := &cobra.Command{ + Use: "export ", Short: "Export an editable Flow update to a JSON file", Args: cobra.ExactArgs(1), + Long: "Writes the current revision, settings, and updated_steps for use with flows update --config. Steps omitted from updated_steps are preserved. Add IDs to deleted_step_ids to remove them. Refuses to overwrite an existing file.", + RunE: func(cmd *cobra.Command, args []string) error { + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + draft, err := client.Flows.GetDraft(cmd.Context(), projectID, args[0]) + if err != nil { + return err + } + body := api.UpdateFlowDraft{FlowSettings: draft.FlowSettings, Revision: draft.Revision, UpdatedSteps: draft.Steps} + data, err := json.MarshalIndent(body, "", " ") + if err != nil { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + return rt.Out.Render(struct { + Path string `json:"path"` + FlowID string `json:"flow_id"` + Revision int `json:"revision"` + }{path, draft.ID, draft.Revision}) + }, + } + cmd.Flags().StringVar(&path, "output", "", "write the editable update to this new file") + _ = cmd.MarkFlagRequired("output") + return cmd +} + +func newFlowsUpdateCmd() *cobra.Command { + var config string + cmd := &cobra.Command{ + Use: "update ", Short: "Save changes to a Flow draft", Args: cobra.ExactArgs(1), + Long: "Saves settings and step changes atomically. The config must include the revision from flows export or flows show. Omitted steps are preserved; deleted_step_ids removes steps explicitly. A stale revision fails without saving. Does not publish.", + RunE: func(cmd *cobra.Command, args []string) error { + var body api.UpdateFlowDraft + if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { + return err + } + if body.Revision < 1 { + return fmt.Errorf("config must include a positive revision from the current draft") + } + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + flow, err := client.Flows.UpdateDraft(cmd.Context(), projectID, args[0], body) + if err != nil { + return err + } + return rt.Out.Render(flow) + }, + } + cmd.Flags().StringVar(&config, "config", "", "JSON update file, or - for stdin (up to 5 MiB)") + _ = cmd.MarkFlagRequired("config") + return cmd +} + +func newFlowsCreateCmd() *cobra.Command { + var config string + cmd := &cobra.Command{ + Use: "create", Short: "Create an unpublished Flow from JSON", Args: cobra.NoArgs, + Long: "Creates a draft with display_name, type (paywall, web-funnel, or general), optional settings, and steps keyed by step ID. Type defaults to general. Does not publish or attach a checkpoint rule.", + RunE: func(cmd *cobra.Command, _ []string) error { + var body api.CreateFlow + if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { + return err + } + if body.DisplayName == nil || *body.DisplayName == "" { + return fmt.Errorf("config must include display_name") + } + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + flow, err := client.Flows.Create(cmd.Context(), projectID, body) + if err != nil { + return err + } + return rt.Out.Render(flow) + }, + } + cmd.Flags().StringVar(&config, "config", "", "JSON flow file, or - for stdin (up to 5 MiB)") + _ = cmd.MarkFlagRequired("config") + return cmd +} + +func newFlowsDuplicateCmd() *cobra.Command { + body := api.DuplicateFlow{} + cmd := &cobra.Command{ + Use: "duplicate ", Short: "Duplicate a Flow into an unpublished draft", Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if body.SourceVersion != "draft" && body.SourceVersion != "published" { + return fmt.Errorf("--source-version must be draft or published") + } + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + flow, err := client.Flows.Duplicate(cmd.Context(), projectID, args[0], body) + if err != nil { + return err + } + return rt.Out.Render(flow) + }, + } + cmd.Flags().StringVar(&body.DisplayName, "name", "", "name of the duplicate") + cmd.Flags().StringVar(&body.SourceVersion, "source-version", "draft", "source to copy: draft or published") + return cmd +} + +func newFlowsValidateCmd() *cobra.Command { + var revision int + cmd := &cobra.Command{ + Use: "validate ", Short: "Validate a saved Flow draft", Args: cobra.ExactArgs(1), + Long: "Returns workflow errors and step issues for the saved draft. Returns a nonzero exit status when the draft is invalid. Does not save or publish.", + RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("revision") && revision < 1 { + return fmt.Errorf("--revision must be positive") + } + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + validation, err := client.Flows.Validate(cmd.Context(), projectID, args[0], revision) + if err != nil { + return err + } + if err := rt.Out.Render(validation); err != nil { + return err + } + if !validation.Valid { + return &SilentExitError{Code: 1} + } + return nil + }, + } + cmd.Flags().IntVar(&revision, "revision", 0, "require this draft revision") + return cmd +} + +func newFlowsPublishCmd() *cobra.Command { + var revision int + cmd := &cobra.Command{ + Use: "publish ", Short: "Validate and publish a specific Flow draft revision", Args: cobra.ExactArgs(1), + Long: "Publishes the specified draft revision, including its screens. This can change flows served through offerings, checkpoint rules, and web links. A stale revision or invalid draft is rejected. Confirmation: prompts under TTY; --yes is required under --no-input.", + RunE: func(cmd *cobra.Command, args []string) error { + if revision < 1 { + return fmt.Errorf("--revision must be positive") + } + rt, client, projectID, err := flowClient(cmd) + if err != nil { + return err + } + if err := confirmOrAbort(rt, fmt.Sprintf("Publish flow %q at revision %d?", args[0], revision)); err != nil { + return err + } + flow, err := client.Flows.Publish(cmd.Context(), projectID, args[0], revision) + if err != nil { + return err + } + return rt.Out.Render(flow) + }, + } + cmd.Flags().IntVar(&revision, "revision", 0, "draft revision to publish") + _ = cmd.MarkFlagRequired("revision") + return cmd +} diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go new file mode 100644 index 00000000..78c9f816 --- /dev/null +++ b/internal/cli/flows_authoring_test.go @@ -0,0 +1,181 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/revenuecat/cli/internal/cli" +) + +func TestFlowExportUpdateRoundTrip(t *testing.T) { + var update map[string]json.RawMessage + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/proj/flows/wf_1/draft" { + t.Errorf("path = %s", r.URL.Path) + } + switch r.Method { + case http.MethodGet: + _, _ = io.WriteString(w, `{"id":"wf_1","object":"flow_draft","revision":4,"type":"web-funnel","display_name":"Checkout","initial_step_id":"screen","single_step_fallback_id":null,"app_id":null,"metadata":{"large":9007199254740993},"custom_context_parameters_config":null,"content_max_width":null,"steps":{"screen":{"type":"screen","paywall":{"components_config":{"large":9007199254740993},"components_localizations":{"en_US":{"title":"Hello"}},"haptics_enabled":false}}}}`) + case http.MethodPatch: + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + t.Error(err) + } + _, _ = io.WriteString(w, `{"id":"wf_1","revision":5}`) + default: + t.Errorf("method = %s", r.Method) + } + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "flow.json") + out, _, err := runProjectSetupCommand(t, server.URL, "flows", "export", "wf_1", "--output", path, "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"schema_version"`) { + t.Fatalf("missing envelope: %s", out) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "schema_version") { + t.Fatal("export contains CLI envelope") + } + _, _, err = runProjectSetupCommand(t, server.URL, "flows", "update", "wf_1", "--config", path, "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if string(update["revision"]) != "4" || string(update["app_id"]) != "null" { + t.Fatalf("revision/null changed: %s", update) + } + if !strings.Contains(string(update["updated_steps"]), "9007199254740993") || !strings.Contains(string(update["metadata"]), "9007199254740993") { + t.Fatal("large integer lost precision") + } + if !strings.Contains(string(update["updated_steps"]), `"haptics_enabled":false`) || !strings.Contains(string(update["updated_steps"]), `"en_US"`) { + t.Fatalf("screen fields lost: %s", update["updated_steps"]) + } + if _, exists := update["deleted_step_ids"]; exists { + t.Fatal("export inferred step deletions") + } + _, _, err = runProjectSetupCommand(t, server.URL, "flows", "export", "wf_1", "--output", path, "--json", "--no-input") + if err == nil { + t.Fatal("export overwrote existing file") + } + after, _ := os.ReadFile(path) + if string(after) != string(data) { + t.Fatal("existing file changed") + } +} + +func TestFlowPublishRequiresConfirmationAndRevision(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != "POST" || r.URL.Path != "/projects/proj/flows/wf_1/actions/publish" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + var body struct { + Revision int `json:"revision"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Revision != 4 { + t.Errorf("revision = %d", body.Revision) + } + _, _ = io.WriteString(w, `{"id":"wf_1","revision":4}`) + })) + defer server.Close() + for _, args := range [][]string{ + {"flows", "publish", "wf_1", "--revision", "4", "--no-input"}, + {"flows", "publish", "wf_1", "--yes", "--no-input"}, + } { + if _, _, err := runProjectSetupCommand(t, server.URL, args...); err == nil { + t.Fatalf("accepted missing confirmation/revision: %v", args) + } + } + if requests != 0 { + t.Fatal("published before confirmation") + } + _, _, err := runProjectSetupCommand(t, server.URL, "flows", "publish", "wf_1", "--revision", "4", "--yes", "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if requests != 1 { + t.Errorf("publish requests = %d", requests) + } +} + +func TestFlowValidationInvalidRendersIssuesAndFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/proj/flows/wf_1/actions/validate" || r.Method != "POST" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = io.WriteString(w, `{"object":"flow_validation","id":"wf_1","revision":4,"valid":false,"step_issues":{},"workflow_errors":[{"key":"no_initial_step","message":"Choose a start step","severity":"error"}]}`) + })) + defer server.Close() + out, _, err := runProjectSetupCommand(t, server.URL, "flows", "validate", "wf_1", "--revision", "4", "--json", "--no-input") + if cli.ExitCodeFor(err) != 1 { + t.Fatalf("exit = %d, error = %v", cli.ExitCodeFor(err), err) + } + if !strings.Contains(out, "Choose a start step") || !strings.Contains(out, `"valid": false`) { + t.Fatalf("validation output = %s", out) + } +} + +func TestFlowUpdateRejectsInvalidConfigsBeforeRequest(t *testing.T) { + for _, data := range []string{`{"display_name":"Missing revision"}`, `{"revision":1,"unknown":true}`, `{"revision":1} {"revision":2}`} { + path := filepath.Join(t.TempDir(), "flow.json") + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + _, _, err := runProjectSetupCommand(t, "http://127.0.0.1:0", "flows", "update", "wf_1", "--config", path, "--no-input") + if err == nil || strings.Contains(err.Error(), "connection refused") { + t.Fatalf("config %s was not rejected before HTTP: %v", data, err) + } + } +} + +func TestFlowCreateAndDuplicateRemainUnpublished(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.Method != "POST" { + t.Errorf("method = %s", r.Method) + } + var body map[string]json.RawMessage + _ = json.NewDecoder(r.Body).Decode(&body) + if r.URL.Path == "/projects/proj/flows" { + if string(body["type"]) != `"paywall"` || !strings.Contains(string(body["steps"]), `"screen"`) { + t.Errorf("create body = %s", body) + } + } else if r.URL.Path == "/projects/proj/flows/wf_1/actions/duplicate" { + if string(body["source_version"]) != `"published"` || string(body["display_name"]) != `"Copy"` { + t.Errorf("duplicate body = %s", body) + } + } else { + t.Errorf("unexpected mutation: %s", r.URL.Path) + } + _, _ = io.WriteString(w, `{"id":"wf_copy","revision":1,"published_at":null}`) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "flow.json") + if err := os.WriteFile(path, []byte(`{"display_name":"Original","type":"paywall","steps":{"screen":{"type":"screen"}}}`), 0o600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"flows", "create", "--config", path, "--json", "--no-input"}, + {"flows", "duplicate", "wf_1", "--name", "Copy", "--source-version", "published", "--json", "--no-input"}, + } { + if _, _, err := runProjectSetupCommand(t, server.URL, args...); err != nil { + t.Fatal(err) + } + } + if len(paths) != 2 { + t.Fatalf("requests = %v", paths) + } +} diff --git a/oapi-codegen.yaml b/oapi-codegen.yaml index 8fd88bba..e1a95259 100644 --- a/oapi-codegen.yaml +++ b/oapi-codegen.yaml @@ -29,3 +29,18 @@ output-options: - App - AppType - AppObject + - ListFlows + - Flow + - CreateFlow + - FlowStep + - FlowScreen + - DuplicateFlow + - PublishFlow + - ValidateFlow + - FlowValidation + - FlowValidationIssue + - PaywallValidationIssueKey + - FlowValidationIssueKey + - FlowValidationError + - FlowDraft + - UpdateFlowDraft From 267a49bf1193d270f6bdad4a7840e121cd92dad3 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Wed, 28 Oct 2026 20:00:02 -0500 Subject: [PATCH 2/8] feat(checkpoints): create checkpoints and filter targeting rules --- docs/command-surface.md | 3 +- docs/specs/cli-coverage.yaml | 2 + docs/specs/v2-beta-overlay.yaml | 29 +++++++++ internal/api/checkpoints.go | 8 +++ internal/api/checkpoints_test.go | 39 ++++++++++++ internal/api/targeting_rules.go | 8 +++ internal/api/types_gen.go | 8 +++ internal/cli/checkpoints.go | 53 +++++++++++++++- internal/cli/checkpoints_test.go | 75 +++++++++++++++++++++++ internal/cli/targeting.go | 8 ++- internal/cli/targeting_checkpoint_test.go | 22 +++++++ 11 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 internal/api/checkpoints_test.go diff --git a/docs/command-surface.md b/docs/command-surface.md index 313c48db..d7ecfef2 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -182,6 +182,7 @@ rc products store screenshot # upload an App Review # Checkpoints (points in the app where Checkpoint rules choose which Flow to show) rc checkpoints list # experimental (hidden from --help until Checkpoint rules roll out) +rc checkpoints create --identifier # register a checkpoint before the SDK encounters it; does not attach or activate a Flow # Paywalls rc paywalls # help; under npx a TTY shows a generate/edit picker (npm launcher sets RC_GUIDED — paywalls-only for now) @@ -220,7 +221,7 @@ rc experiments resume # resumes enrollment; con rc experiments stop # permanent; confirmation/--yes # Targeting rules — development v2 endpoints -rc targeting list [--state ] +rc targeting list [--state ] [--checkpoint-id ] [--flow-id ] rc targeting show # human detail view shows audience, placements, schedule, and checkpoints rc targeting create (--name --offering [--state inactive|active] | --config ) # initially inactive rc targeting update --config # partial update of an Offering or checkpoint rule diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index 75333f8a..5f90f892 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -229,6 +229,8 @@ endpoints: # Checkpoints (development-status; provided by the beta overlay) - method: GET path: /projects/{project_id}/checkpoints + - method: POST + path: /projects/{project_id}/checkpoints # Audiences (provided by the beta overlay until the published spec includes them) - method: GET diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index a023c99f..52fcc775 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -1491,6 +1491,26 @@ paths: responses: '200': description: OK + post: + summary: Create a Checkpoint + operationId: create-checkpoint + x-release-status: development + x-revenuecat-release-gatekeeping: false + x-scopes: + - project_configuration:targeting_rules:read_write + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCheckpointRequest' + responses: + '201': + description: The Checkpoint was created + '403': + description: Checkpoint access or the required scope is missing + '409': + description: A Checkpoint with this identifier already exists /projects/{project_id}/audiences: parameters: - name: project_id @@ -1778,6 +1798,15 @@ paths: description: OK components: schemas: + CreateCheckpointRequest: + type: object + required: [identifier] + additionalProperties: false + properties: + identifier: + type: string + minLength: 1 + maxLength: 255 Currency: type: string description: ISO 4217 currency code diff --git a/internal/api/checkpoints.go b/internal/api/checkpoints.go index d5fcf470..2b7360b5 100644 --- a/internal/api/checkpoints.go +++ b/internal/api/checkpoints.go @@ -26,3 +26,11 @@ func (s *CheckpointsService) List(ctx context.Context, projectID string, opts *L } return &out, nil } + +func (s *CheckpointsService) Create(ctx context.Context, projectID string, body CreateCheckpointRequest) (*Checkpoint, error) { + var out Checkpoint + if err := s.c.do(ctx, http.MethodPost, pathCheckpoints(projectID), body, &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/internal/api/checkpoints_test.go b/internal/api/checkpoints_test.go new file mode 100644 index 00000000..198a8d03 --- /dev/null +++ b/internal/api/checkpoints_test.go @@ -0,0 +1,39 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/revenuecat/cli/internal/api" +) + +func TestCheckpointsCreate(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/checkpoints" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if len(body) != 1 || body["identifier"] != "after_onboarding" { + t.Errorf("unexpected body: %v", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, `{"id":"chkpt1","object":"checkpoint","project_id":"proj","identifier":"after_onboarding","created_via":"api"}`) + })) + t.Cleanup(srv.Close) + client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL}) + checkpoint, err := client.Checkpoints.Create(context.Background(), "proj", api.CreateCheckpointRequest{Identifier: "after_onboarding"}) + if err != nil { + t.Fatal(err) + } + if checkpoint.ID != "chkpt1" || checkpoint.Identifier != "after_onboarding" || checkpoint.ProjectID != "proj" || checkpoint.CreatedVia != "api" { + t.Fatalf("unexpected checkpoint: %+v", checkpoint) + } +} diff --git a/internal/api/targeting_rules.go b/internal/api/targeting_rules.go index e2bb4c3e..02236229 100644 --- a/internal/api/targeting_rules.go +++ b/internal/api/targeting_rules.go @@ -29,6 +29,8 @@ type ListTargetingRulesOptions struct { State string Limit int StartingAfter string + CheckpointID string + FlowID string } func (s *TargetingRulesService) List(ctx context.Context, projectID string, opts ListTargetingRulesOptions) (*Page[TargetingRule], error) { @@ -42,6 +44,12 @@ func (s *TargetingRulesService) List(ctx context.Context, projectID string, opts if opts.StartingAfter != "" { q.Set("starting_after", opts.StartingAfter) } + if opts.CheckpointID != "" { + q.Set("checkpoint_id", opts.CheckpointID) + } + if opts.FlowID != "" { + q.Set("flow_id", opts.FlowID) + } path := pathTargetingRules(projectID) if len(q) > 0 { path += "?" + q.Encode() diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 08976400..35aa6744 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -35453,6 +35453,11 @@ type CreateAppStoreConnectSubscriptionInput struct { // CreateAppStoreConnectSubscriptionInputDuration The subscription duration period type CreateAppStoreConnectSubscriptionInputDuration string +// CreateCheckpointRequest defines model for CreateCheckpointRequest. +type CreateCheckpointRequest struct { + Identifier string `json:"identifier"` +} + // CreateWebhookIntegrationInput defines model for CreateWebhookIntegrationInput. type CreateWebhookIntegrationInput struct { // AppID The ID of the app the webhook integration is scoped to @@ -45456,6 +45461,9 @@ type CreateAppJSONRequestBody = AppCreate // UpdateAppJSONRequestBody defines body for UpdateApp for application/json ContentType. type UpdateAppJSONRequestBody UpdateAppJSONBody +// CreateCheckpointJSONRequestBody defines body for CreateCheckpoint for application/json ContentType. +type CreateCheckpointJSONRequestBody = CreateCheckpointRequest + // CreateCustomerJSONRequestBody defines body for CreateCustomer for application/json ContentType. type CreateCustomerJSONRequestBody CreateCustomerJSONBody diff --git a/internal/cli/checkpoints.go b/internal/cli/checkpoints.go index a0172dd5..ee23f9df 100644 --- a/internal/cli/checkpoints.go +++ b/internal/cli/checkpoints.go @@ -1,10 +1,15 @@ package cli import ( + "fmt" + "strings" + + "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/revenuecat/cli/internal/api" "github.com/revenuecat/cli/internal/output" + "github.com/revenuecat/cli/internal/tui" ) func newCheckpointsCmd() *cobra.Command { @@ -13,12 +18,56 @@ func newCheckpointsCmd() *cobra.Command { Aliases: []string{"checkpoint"}, // Hidden from --help until Checkpoint rules roll out. Annotations: map[string]string{annotationSurface: surfaceExperimental}, - Short: "View Checkpoints", + Short: "Manage Checkpoints", Long: `A Checkpoint is a point in your app, such as app open or after onboarding, where Checkpoint rules decide which Flow a customer sees.`, Example: ` rc checkpoints list`, } - cmd.AddCommand(newCheckpointsListCmd()) + cmd.AddCommand(newCheckpointsListCmd(), newCheckpointsCreateCmd()) + return cmd +} + +func newCheckpointsCreateCmd() *cobra.Command { + var identifier string + cmd := &cobra.Command{ + Use: "create", + Short: "Create a Checkpoint", + Long: `Registers the identifier your app will use before the SDK encounters it. +Requires Checkpoint access for the project. Creating a Checkpoint does not +attach or activate a Flow; use targeting rules to choose what it shows.`, + Example: ` rc checkpoints create --identifier after_onboarding + rc checkpoints create --identifier after_onboarding --json --no-input`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + validate := func(value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("--identifier is required") + } + return nil + } + if err := tui.Form(rt.Globals.NoInput). + Field(huh.NewInput().Title("Identifier").Value(&identifier).Validate(validate)). + Run(); err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + checkpoint, err := client.Checkpoints.Create(cmd.Context(), projectID, api.CreateCheckpointRequest{Identifier: identifier}) + if err != nil { + return err + } + rt.Out.Success(fmt.Sprintf("Created checkpoint %s", checkpoint.ID)) + return rt.Out.Render(checkpoint) + }, + } + cmd.Flags().StringVar(&identifier, "identifier", "", "identifier your app uses for the Checkpoint (required)") return cmd } diff --git a/internal/cli/checkpoints_test.go b/internal/cli/checkpoints_test.go index c7950740..e8658d9d 100644 --- a/internal/cli/checkpoints_test.go +++ b/internal/cli/checkpoints_test.go @@ -1,6 +1,7 @@ package cli_test import ( + "encoding/json" "io" "net/http" "net/http/httptest" @@ -8,6 +9,80 @@ import ( "testing" ) +func TestCheckpointsCreate(t *testing.T) { + for _, jsonOutput := range []bool{false, true} { + name := "human" + if jsonOutput { + name = "json" + } + t.Run(name, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodPost || r.URL.Path != "/projects/proj/checkpoints" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["identifier"] != "after_onboarding" { + t.Errorf("unexpected body: %v", body) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":"chkpt1","object":"checkpoint","identifier":"after_onboarding","created_via":"api"}`) + })) + t.Cleanup(server.Close) + args := []string{"checkpoints", "create", "--identifier", "after_onboarding", "--no-input"} + if jsonOutput { + args = append(args, "--json") + } + out, _, err := runProjectSetupCommand(t, server.URL, args...) + if err != nil || requests != 1 { + t.Fatalf("err=%v requests=%d", err, requests) + } + for _, want := range []string{"chkpt1", "after_onboarding"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q: %s", want, out) + } + } + if jsonOutput && !strings.Contains(out, `"schema_version": 1`) { + t.Errorf("missing JSON envelope: %s", out) + } + }) + } +} + +func TestCheckpointsCreateRequiresIdentifierBeforeRequest(t *testing.T) { + for _, identifier := range []string{"", " "} { + t.Run(identifier, func(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + })) + t.Cleanup(server.Close) + _, _, err := runProjectSetupCommand(t, server.URL, "checkpoints", "create", "--identifier", identifier, "--no-input") + if err == nil || !strings.Contains(err.Error(), "--identifier") || requests != 0 { + t.Fatalf("err=%v requests=%d", err, requests) + } + }) + } +} + +func TestCheckpointsCreateSurfacesAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"type":"resource_already_exists","message":"Checkpoint already exists","retryable":false}`) + })) + t.Cleanup(server.Close) + out, _, err := runProjectSetupCommand(t, server.URL, "checkpoints", "create", "--identifier", "after_onboarding", "--json", "--no-input") + if err == nil || !strings.Contains(err.Error(), "Checkpoint already exists") || strings.Contains(out, `"data"`) { + t.Fatalf("out=%s err=%v", out, err) + } +} + func TestCheckpointsList_ShowsIdentifiersAndPaginates(t *testing.T) { var gotLimit, gotCursor string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cli/targeting.go b/internal/cli/targeting.go index 10c071dc..019f0af3 100644 --- a/internal/cli/targeting.go +++ b/internal/cli/targeting.go @@ -24,7 +24,7 @@ func newTargetingCmd() *cobra.Command { } func newTargetingListCmd() *cobra.Command { - var state, cursor string + var state, cursor, checkpointID, flowID string var limit int cmd := &cobra.Command{ Use: "list", @@ -41,7 +41,9 @@ func newTargetingListCmd() *cobra.Command { if err != nil { return err } - page, err := client.TargetingRules.List(cmd.Context(), projectID, api.ListTargetingRulesOptions{State: state, Limit: limit, StartingAfter: cursor}) + page, err := client.TargetingRules.List(cmd.Context(), projectID, api.ListTargetingRulesOptions{ + State: state, Limit: limit, StartingAfter: cursor, CheckpointID: checkpointID, FlowID: flowID, + }) if err != nil { return err } @@ -66,6 +68,8 @@ func newTargetingListCmd() *cobra.Command { cmd.Flags().StringVar(&state, "state", "", "filter by active, scheduled (checkpoint only), or inactive; future-scheduled Offering rules are active") cmd.Flags().IntVar(&limit, "limit", 20, "maximum rules to return (1–100)") cmd.Flags().StringVar(&cursor, "cursor", "", "item ID to start after (pagination)") + cmd.Flags().StringVar(&checkpointID, "checkpoint-id", "", "filter rules by Checkpoint ID") + cmd.Flags().StringVar(&flowID, "flow-id", "", "filter rules by Flow ID") return cmd } diff --git a/internal/cli/targeting_checkpoint_test.go b/internal/cli/targeting_checkpoint_test.go index e49389da..25ef2df5 100644 --- a/internal/cli/targeting_checkpoint_test.go +++ b/internal/cli/targeting_checkpoint_test.go @@ -12,6 +12,28 @@ import ( "testing" ) +func TestCheckpointTargetingListFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/projects/proj/targeting_rules" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + for key, want := range map[string]string{ + "checkpoint_id": "chkpt1", "flow_id": "wf1", "state": "inactive", "limit": "3", "starting_after": "chkptrule0", + } { + if got := r.URL.Query().Get(key); got != want { + t.Errorf("%s=%q, want %q", key, got, want) + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"object":"list","items":[{"id":"chkptrule1","rule_type":"checkpoint","flow_id":"wf1","state":"inactive","display_name":"Onboarding"}],"next_page":null}`) + })) + t.Cleanup(server.Close) + out, _, err := runProjectSetupCommand(t, server.URL, "targeting", "list", "--checkpoint-id", "chkpt1", "--flow-id", "wf1", "--state", "inactive", "--limit", "3", "--cursor", "chkptrule0", "--json", "--no-input") + if err != nil || !strings.Contains(out, "chkptrule1") { + t.Fatalf("out=%s err=%v", out, err) + } +} + func TestCheckpointTargetingCreatePreservesPositionAfterApproval(t *testing.T) { for _, position := range []int{0, 3} { t.Run(fmt.Sprint(position), func(t *testing.T) { From afe9eec19541ddcdb769adc6ddca29d915687c72 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 08:58:56 +0000 Subject: [PATCH 3/8] refactor(flows): review fixes for #201 Co-Authored-By: Claude Sonnet 5.5 --- docs/command-surface.md | 12 +- docs/specs/cli-coverage.yaml | 2 - internal/api/flows.go | 36 +-- internal/api/flows_authoring_test.go | 4 +- internal/api/paths_gen.go | 4 - internal/api/types_gen.go | 451 +++++++++++++++++++++++++++ internal/cli/checkpoints.go | 9 +- internal/cli/checkpoints_test.go | 2 +- internal/cli/flows.go | 15 + internal/cli/flows_authoring.go | 161 +++++++--- internal/cli/flows_authoring_test.go | 10 +- internal/tui/prompt.go | 3 +- oapi-codegen.yaml | 5 - 13 files changed, 599 insertions(+), 115 deletions(-) diff --git a/docs/command-surface.md b/docs/command-surface.md index d7ecfef2..3a1462f8 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -199,13 +199,13 @@ rc paywalls delete [--force] # attached/published pa # Flows (paywalls, web funnels, and multi-step experiences) rc flows list [--type paywall|web-funnel|general] [--exclude-type ...] # experimental (hidden from --help until Checkpoint rules roll out); repeatable; shows type and publish state -rc flows show # editable draft, including steps and screen content -rc flows export --output # save an update request containing the current revision and all steps; refuses to overwrite -rc flows update --config # save the exported JSON or a partial update; only deleted_step_ids removes steps +rc flows show [id] # editable draft, including steps and screen content +rc flows export [id] --output # save an update request containing the current revision and all steps +rc flows update [id] --config # save the exported JSON or a partial update; only deleted_step_ids removes steps rc flows create --config # create an unpublished paywall, web-funnel, or general flow -rc flows duplicate [--name ] [--source-version draft|published] # create an unpublished copy -rc flows validate [--revision ] # return saved-draft validation issues -rc flows publish --revision # validate and publish that revision; confirmation/--yes +rc flows duplicate [id] [--name ] [--source-version draft|published] # create an unpublished copy +rc flows validate [id] [--revision ] # return saved-draft validation issues +rc flows publish [id] --revision # validate and publish that revision; confirmation/--yes # Experiments — development v2 endpoints, normal discoverable CLI surface rc experiments list [--status ] rc experiments show # human detail view shows variants, targeting, placements, and chronological timeline diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index 5f90f892..e2cb70f0 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -295,8 +295,6 @@ endpoints: # Flow authoring (development API) - method: POST path: /projects/{project_id}/flows - - method: GET - path: /projects/{project_id}/flows/{flow_id} - method: POST path: /projects/{project_id}/flows/{flow_id}/actions/duplicate - method: POST diff --git a/internal/api/flows.go b/internal/api/flows.go index a49fe254..e168155e 100644 --- a/internal/api/flows.go +++ b/internal/api/flows.go @@ -55,19 +55,6 @@ type CreateFlow struct { Steps map[string]json.RawMessage `json:"steps,omitempty"` } -type DuplicateFlow struct { - DisplayName string `json:"display_name,omitempty"` - SourceVersion string `json:"source_version,omitempty"` -} - -type ValidateFlow struct { - Revision int `json:"revision,omitempty"` -} - -type PublishFlow struct { - Revision int `json:"revision"` -} - type FlowValidationIssue struct { Key string `json:"key"` Message string `json:"message"` @@ -80,15 +67,6 @@ type FlowValidationIssue struct { Action *string `json:"action,omitempty"` } -type FlowValidation struct { - ID string `json:"id"` - Object string `json:"object"` - Revision *int `json:"revision"` - Valid bool `json:"valid"` - StepIssues map[string][]FlowValidationIssue `json:"step_issues"` - WorkflowErrors []FlowValidationIssue `json:"workflow_errors"` -} - // FlowListOptions filters by Flow type. Type and ExcludeType can't be combined. type FlowListOptions struct { ListOptions @@ -124,12 +102,6 @@ func (s *FlowsService) List(ctx context.Context, projectID string, opts FlowList return &out, nil } -func (s *FlowsService) Get(ctx context.Context, projectID, id string) (*Flow, error) { - var out Flow - err := s.c.do(ctx, http.MethodGet, pathFlow(projectID, id), nil, &out) - return &out, err -} - func (s *FlowsService) GetDraft(ctx context.Context, projectID, id string) (*FlowDraft, error) { var out FlowDraft err := s.c.do(ctx, http.MethodGet, pathFlowDraft(projectID, id), nil, &out) @@ -156,14 +128,18 @@ func (s *FlowsService) Duplicate(ctx context.Context, projectID, id string, body func (s *FlowsService) Validate(ctx context.Context, projectID, id string, revision int) (*FlowValidation, error) { var out FlowValidation - body := ValidateFlow{Revision: revision} + var body ValidateFlow + if revision > 0 { + r := int64(revision) + body.Revision = &r + } err := s.c.do(ctx, http.MethodPost, pathFlowActionsValidate(projectID, id), body, &out) return &out, err } func (s *FlowsService) Publish(ctx context.Context, projectID, id string, revision int) (*Flow, error) { var out Flow - body := PublishFlow{Revision: revision} + body := PublishFlow{Revision: int64(revision)} err := s.c.do(ctx, http.MethodPost, pathFlowActionsPublish(projectID, id), body, &out) return &out, err } diff --git a/internal/api/flows_authoring_test.go b/internal/api/flows_authoring_test.go index 9a82b476..22b8cc9c 100644 --- a/internal/api/flows_authoring_test.go +++ b/internal/api/flows_authoring_test.go @@ -14,11 +14,11 @@ import ( ) func TestFlowAuthoringRoutes(t *testing.T) { + published := api.DuplicateFlowSourceVersionPublished for _, tc := range []struct { name, method, suffix, body string call func(*api.Client) error }{ - {"get", "GET", "", "", func(c *api.Client) error { _, err := c.Flows.Get(context.Background(), "proj", "wf_1"); return err }}, {"draft", "GET", "/draft", "", func(c *api.Client) error { _, err := c.Flows.GetDraft(context.Background(), "proj", "wf_1") return err @@ -28,7 +28,7 @@ func TestFlowAuthoringRoutes(t *testing.T) { return err }}, {"duplicate", "POST", "/actions/duplicate", `{"source_version":"published"}`, func(c *api.Client) error { - _, err := c.Flows.Duplicate(context.Background(), "proj", "wf_1", api.DuplicateFlow{SourceVersion: "published"}) + _, err := c.Flows.Duplicate(context.Background(), "proj", "wf_1", api.DuplicateFlow{SourceVersion: &published}) return err }}, {"validate latest", "POST", "/actions/validate", `{}`, func(c *api.Client) error { diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index 60aa5953..dc1c560d 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -162,10 +162,6 @@ func pathExperiments(projectID string) string { return encodePath("projects", projectID, "experiments") } -func pathFlow(projectID string, flowID string) string { - return encodePath("projects", projectID, "flows", flowID) -} - func pathFlowActionsDuplicate(projectID string, flowID string) string { return encodePath("projects", projectID, "flows", flowID, "actions", "duplicate") } diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 35aa6744..802aa3ed 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -1975,6 +1975,24 @@ func (e DeletedObjectObject) Valid() bool { } } +// Defines values for DuplicateFlowSourceVersion. +const ( + DuplicateFlowSourceVersionDraft DuplicateFlowSourceVersion = "draft" + DuplicateFlowSourceVersionPublished DuplicateFlowSourceVersion = "published" +) + +// Valid indicates whether the value is a known member of the DuplicateFlowSourceVersion enum. +func (e DuplicateFlowSourceVersion) Valid() bool { + switch e { + case DuplicateFlowSourceVersionDraft: + return true + case DuplicateFlowSourceVersionPublished: + return true + default: + return false + } +} + // Defines values for Duration. const ( P1M Duration = "P1M" @@ -2155,6 +2173,90 @@ func (e ExtendSubscriptionUntilDateExtendReasonCode) Valid() bool { } } +// Defines values for FlowValidationObject. +const ( + FlowValidationObjectFlowValidation FlowValidationObject = "flow_validation" +) + +// Valid indicates whether the value is a known member of the FlowValidationObject enum. +func (e FlowValidationObject) Valid() bool { + switch e { + case FlowValidationObjectFlowValidation: + return true + default: + return false + } +} + +// Defines values for FlowValidationErrorKey. +const ( + WorkflowCheckoutNoSuccessStep FlowValidationErrorKey = "Workflow_CheckoutNoSuccessStep" + WorkflowCheckoutNotReachable FlowValidationErrorKey = "Workflow_CheckoutNotReachable" + WorkflowCookieConsentNoPrivacyPolicy FlowValidationErrorKey = "Workflow_CookieConsentNoPrivacyPolicy" + WorkflowExclusiveRuleMixedOutcome FlowValidationErrorKey = "Workflow_ExclusiveRuleMixedOutcome" + WorkflowExperimentPotentialCombosOverLimit FlowValidationErrorKey = "Workflow_ExperimentPotentialCombosOverLimit" + WorkflowExperimentRunningCombosOverLimit FlowValidationErrorKey = "Workflow_ExperimentRunningCombosOverLimit" + WorkflowInitialStepNotFound FlowValidationErrorKey = "Workflow_InitialStepNotFound" + WorkflowNoCheckoutStep FlowValidationErrorKey = "Workflow_NoCheckoutStep" + WorkflowNoInitialStep FlowValidationErrorKey = "Workflow_NoInitialStep" + WorkflowNoPaywallScreen FlowValidationErrorKey = "Workflow_NoPaywallScreen" + WorkflowNoWebConfig FlowValidationErrorKey = "Workflow_NoWebConfig" + WorkflowNoWebProducts FlowValidationErrorKey = "Workflow_NoWebProducts" + WorkflowRestoreButtonNotOnFinalScreen FlowValidationErrorKey = "Workflow_RestoreButtonNotOnFinalScreen" +) + +// Valid indicates whether the value is a known member of the FlowValidationErrorKey enum. +func (e FlowValidationErrorKey) Valid() bool { + switch e { + case WorkflowCheckoutNoSuccessStep: + return true + case WorkflowCheckoutNotReachable: + return true + case WorkflowCookieConsentNoPrivacyPolicy: + return true + case WorkflowExclusiveRuleMixedOutcome: + return true + case WorkflowExperimentPotentialCombosOverLimit: + return true + case WorkflowExperimentRunningCombosOverLimit: + return true + case WorkflowInitialStepNotFound: + return true + case WorkflowNoCheckoutStep: + return true + case WorkflowNoInitialStep: + return true + case WorkflowNoPaywallScreen: + return true + case WorkflowNoWebConfig: + return true + case WorkflowNoWebProducts: + return true + case WorkflowRestoreButtonNotOnFinalScreen: + return true + default: + return false + } +} + +// Defines values for FlowValidationErrorSeverity. +const ( + FlowValidationErrorSeverityError FlowValidationErrorSeverity = "error" + FlowValidationErrorSeverityWarning FlowValidationErrorSeverity = "warning" +) + +// Valid indicates whether the value is a known member of the FlowValidationErrorSeverity enum. +func (e FlowValidationErrorSeverity) Valid() bool { + switch e { + case FlowValidationErrorSeverityError: + return true + case FlowValidationErrorSeverityWarning: + return true + default: + return false + } +} + // Defines values for IndicativePriceObject. const ( IndicativePriceObjectIndicativePrice IndicativePriceObject = "indicative_price" @@ -35690,6 +35792,24 @@ type DeletedObject struct { // DeletedObjectObject The type of the deleted object type DeletedObjectObject string +// DuplicateFlow defines model for DuplicateFlow. +type DuplicateFlow struct { + // DisplayName Name of the duplicate. + // + // Example: Onboarding Copy + DisplayName *string `json:"display_name,omitempty"` + + // SourceVersion Which source version to copy. The copy is always unpublished. + // + // Example: draft + SourceVersion *DuplicateFlowSourceVersion `json:"source_version,omitempty"` +} + +// DuplicateFlowSourceVersion Which source version to copy. The copy is always unpublished. +// +// Example: draft +type DuplicateFlowSourceVersion string + // Duration The duration of the product subscription. This field is only supported for the test store and it is ignored for other stores. // // Example: P1W @@ -35834,6 +35954,79 @@ type ExtendSubscriptionUntilDate struct { // Example: customer_satisfaction type ExtendSubscriptionUntilDateExtendReasonCode string +// FlowValidation defines model for FlowValidation. +type FlowValidation struct { + // ID Flow ID. + // + // Example: wf_123456789a + ID string `json:"id"` + + // Object Type of this object. + // + // Example: flow_validation + Object FlowValidationObject `json:"object"` + + // Revision Current draft revision. Saves must supply this value to reject stale writes. + // + // Example: 1 + Revision *int64 `json:"revision"` + + // StepIssues Issues keyed by step ID. + // + // Example: {} + StepIssues map[string][]FlowValidationIssue `json:"step_issues"` + + // Valid Whether the draft can be published. Warnings do not prevent publication. + // + // Example: false + Valid bool `json:"valid"` + + // WorkflowErrors Issues affecting the entire flow. + // + // Example: [] + WorkflowErrors []FlowValidationError `json:"workflow_errors"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// FlowValidationObject Type of this object. +// +// Example: flow_validation +type FlowValidationObject string + +// FlowValidationError defines model for FlowValidationError. +type FlowValidationError struct { + // Action Suggested action to resolve the issue. + // + // Example: add_screen + Action *string `json:"action,omitempty"` + + // Key Workflow validation error key. + // + // Example: Workflow_ExperimentRunningCombosOverLimit + Key FlowValidationErrorKey `json:"key"` + + // Message A human-readable description of the issue. + // + // Example: Add a name to your paywall. + Message string `json:"message"` + + // Severity The severity of the validation issue. + // + // Example: error + Severity FlowValidationErrorSeverity `json:"severity"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// FlowValidationErrorKey Workflow validation error key. +// +// Example: Workflow_ExperimentRunningCombosOverLimit +type FlowValidationErrorKey string + +// FlowValidationErrorSeverity The severity of the validation issue. +// +// Example: error +type FlowValidationErrorSeverity string + // IndicativePrice An indicative price for a product, in the default currency/country (USD / US). Uses the same price source as the get-offering-prices endpoint. type IndicativePrice struct { // AmountMicros The price in micros. For example, USD 1 would be represented as 1000000 @@ -37222,6 +37415,14 @@ type PublicAPIKeyEnvironment string // PublicAPIKeyObject String representing the object's type. Objects of the same type share the same value. type PublicAPIKeyObject string +// PublishFlow defines model for PublishFlow. +type PublishFlow struct { + // Revision Current draft revision. Saves must supply this value to reject stale writes. + // + // Example: 1 + Revision int64 `json:"revision"` +} + // Purchase defines model for Purchase. type Purchase struct { // Country The country that the object is associated with, in ISO alpha 2 code @@ -38121,6 +38322,14 @@ type UpdateWebhookIntegrationInput struct { // UpdateWebhookIntegrationInputEnvironment The environment the webhook integration is configured for type UpdateWebhookIntegrationInputEnvironment string +// ValidateFlow defines model for ValidateFlow. +type ValidateFlow struct { + // Revision Current draft revision. Saves must supply this value to reject stale writes. + // + // Example: 1 + Revision *int64 `json:"revision,omitempty"` +} + // VirtualCurrency defines model for VirtualCurrency. type VirtualCurrency struct { // Code The unique code for this virtual currency @@ -46586,6 +46795,248 @@ func (a ChartSeries) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// Getter for additional properties for FlowValidation. Returns the specified +// element and whether it was found +func (a FlowValidation) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for FlowValidation +func (a *FlowValidation) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for FlowValidation to handle AdditionalProperties +func (a *FlowValidation) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["id"]; found { + err = json.Unmarshal(raw, &a.ID) + if err != nil { + return fmt.Errorf("error reading 'id': %w", err) + } + delete(object, "id") + } + + if raw, found := object["object"]; found { + err = json.Unmarshal(raw, &a.Object) + if err != nil { + return fmt.Errorf("error reading 'object': %w", err) + } + delete(object, "object") + } + + if raw, found := object["revision"]; found { + err = json.Unmarshal(raw, &a.Revision) + if err != nil { + return fmt.Errorf("error reading 'revision': %w", err) + } + delete(object, "revision") + } + + if raw, found := object["step_issues"]; found { + err = json.Unmarshal(raw, &a.StepIssues) + if err != nil { + return fmt.Errorf("error reading 'step_issues': %w", err) + } + delete(object, "step_issues") + } + + if raw, found := object["valid"]; found { + err = json.Unmarshal(raw, &a.Valid) + if err != nil { + return fmt.Errorf("error reading 'valid': %w", err) + } + delete(object, "valid") + } + + if raw, found := object["workflow_errors"]; found { + err = json.Unmarshal(raw, &a.WorkflowErrors) + if err != nil { + return fmt.Errorf("error reading 'workflow_errors': %w", err) + } + delete(object, "workflow_errors") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for FlowValidation to handle AdditionalProperties +func (a FlowValidation) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["id"], err = json.Marshal(a.ID) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + + object["object"], err = json.Marshal(a.Object) + if err != nil { + return nil, fmt.Errorf("error marshaling 'object': %w", err) + } + + object["revision"], err = json.Marshal(a.Revision) + if err != nil { + return nil, fmt.Errorf("error marshaling 'revision': %w", err) + } + + if a.StepIssues != nil { + object["step_issues"], err = json.Marshal(a.StepIssues) + if err != nil { + return nil, fmt.Errorf("error marshaling 'step_issues': %w", err) + } + } + + object["valid"], err = json.Marshal(a.Valid) + if err != nil { + return nil, fmt.Errorf("error marshaling 'valid': %w", err) + } + + if a.WorkflowErrors != nil { + object["workflow_errors"], err = json.Marshal(a.WorkflowErrors) + if err != nil { + return nil, fmt.Errorf("error marshaling 'workflow_errors': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for FlowValidationError. Returns the specified +// element and whether it was found +func (a FlowValidationError) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for FlowValidationError +func (a *FlowValidationError) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for FlowValidationError to handle AdditionalProperties +func (a *FlowValidationError) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["action"]; found { + err = json.Unmarshal(raw, &a.Action) + if err != nil { + return fmt.Errorf("error reading 'action': %w", err) + } + delete(object, "action") + } + + if raw, found := object["key"]; found { + err = json.Unmarshal(raw, &a.Key) + if err != nil { + return fmt.Errorf("error reading 'key': %w", err) + } + delete(object, "key") + } + + if raw, found := object["message"]; found { + err = json.Unmarshal(raw, &a.Message) + if err != nil { + return fmt.Errorf("error reading 'message': %w", err) + } + delete(object, "message") + } + + if raw, found := object["severity"]; found { + err = json.Unmarshal(raw, &a.Severity) + if err != nil { + return fmt.Errorf("error reading 'severity': %w", err) + } + delete(object, "severity") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for FlowValidationError to handle AdditionalProperties +func (a FlowValidationError) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.Action != nil { + object["action"], err = json.Marshal(a.Action) + if err != nil { + return nil, fmt.Errorf("error marshaling 'action': %w", err) + } + } + + object["key"], err = json.Marshal(a.Key) + if err != nil { + return nil, fmt.Errorf("error marshaling 'key': %w", err) + } + + object["message"], err = json.Marshal(a.Message) + if err != nil { + return nil, fmt.Errorf("error marshaling 'message': %w", err) + } + + object["severity"], err = json.Marshal(a.Severity) + if err != nil { + return nil, fmt.Errorf("error marshaling 'severity': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // Getter for additional properties for MacAppStoreApp. Returns the specified // element and whether it was found func (a MacAppStoreApp) Get(fieldName string) (value interface{}, found bool) { diff --git a/internal/cli/checkpoints.go b/internal/cli/checkpoints.go index ee23f9df..9a25f809 100644 --- a/internal/cli/checkpoints.go +++ b/internal/cli/checkpoints.go @@ -2,7 +2,6 @@ package cli import ( "fmt" - "strings" "github.com/charmbracelet/huh" "github.com/spf13/cobra" @@ -44,14 +43,8 @@ attach or activate a Flow; use targeting rules to choose what it shows.`, if err != nil { return err } - validate := func(value string) error { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("--identifier is required") - } - return nil - } if err := tui.Form(rt.Globals.NoInput). - Field(huh.NewInput().Title("Identifier").Value(&identifier).Validate(validate)). + Field(huh.NewInput().Title("Identifier").Value(&identifier).Validate(tui.Required("identifier"))). Run(); err != nil { return err } diff --git a/internal/cli/checkpoints_test.go b/internal/cli/checkpoints_test.go index e8658d9d..35462725 100644 --- a/internal/cli/checkpoints_test.go +++ b/internal/cli/checkpoints_test.go @@ -63,7 +63,7 @@ func TestCheckpointsCreateRequiresIdentifierBeforeRequest(t *testing.T) { })) t.Cleanup(server.Close) _, _, err := runProjectSetupCommand(t, server.URL, "checkpoints", "create", "--identifier", identifier, "--no-input") - if err == nil || !strings.Contains(err.Error(), "--identifier") || requests != 0 { + if err == nil || !strings.Contains(err.Error(), "identifier") || requests != 0 { t.Fatalf("err=%v requests=%d", err, requests) } }) diff --git a/internal/cli/flows.go b/internal/cli/flows.go index e5f125ed..cfde163a 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -1,6 +1,9 @@ package cli import ( + "context" + "fmt" + "github.com/spf13/cobra" "github.com/revenuecat/cli/internal/api" @@ -81,3 +84,15 @@ func flowPublishState(f api.Flow) string { return formatMillis(int64(*f.PublishedAt)) } } + +func flowPickerItems(ctx context.Context, client *api.Client, projectID string) ([]PickerItem, error) { + page, err := client.Flows.List(ctx, projectID, api.FlowListOptions{}) + if err != nil { + return nil, err + } + items := make([]PickerItem, len(page.Items)) + for i, f := range page.Items { + items[i] = PickerItem{ID: f.ID, Label: fmt.Sprintf("%s (%s)", f.Name, f.Type)} + } + return items, nil +} diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go index 0a0d1c07..8f0f6e5d 100644 --- a/internal/cli/flows_authoring.go +++ b/internal/cli/flows_authoring.go @@ -12,25 +12,28 @@ import ( const maxFlowConfigBytes = 5 << 20 -func flowClient(cmd *cobra.Command) (*Runtime, *api.Client, string, error) { - rt := RuntimeFrom(cmd.Context()) - projectID, err := requireProject(rt) - if err != nil { - return nil, nil, "", err - } - client, err := rt.API() - return rt, client, projectID, err -} - func newFlowsShowCmd() *cobra.Command { return &cobra.Command{ - Use: "show ", Short: "Show a Flow's editable draft", Args: cobra.ExactArgs(1), + Use: "show [id]", Short: "Show a Flow's editable draft", Args: cobra.MaximumNArgs(1), + Example: ` rc flows show wf_abc + rc flows show wf_abc --json`, RunE: func(cmd *cobra.Command, args []string) error { - rt, client, projectID, err := flowClient(cmd) + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() if err != nil { return err } - draft, err := client.Flows.GetDraft(cmd.Context(), projectID, args[0]) + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + draft, err := client.Flows.GetDraft(cmd.Context(), projectID, flowID) if err != nil { return err } @@ -42,31 +45,36 @@ func newFlowsShowCmd() *cobra.Command { func newFlowsExportCmd() *cobra.Command { var path string cmd := &cobra.Command{ - Use: "export ", Short: "Export an editable Flow update to a JSON file", Args: cobra.ExactArgs(1), - Long: "Writes the current revision, settings, and updated_steps for use with flows update --config. Steps omitted from updated_steps are preserved. Add IDs to deleted_step_ids to remove them. Refuses to overwrite an existing file.", + Use: "export [id]", Short: "Export an editable Flow update to a JSON file", Args: cobra.MaximumNArgs(1), + Long: "Writes the current revision, settings, and updated_steps for use with flows update --config. Steps omitted from updated_steps are preserved. Add IDs to deleted_step_ids to remove them.", + Example: ` rc flows export wf_abc --output flow.json + rc flows update wf_abc --config flow.json`, RunE: func(cmd *cobra.Command, args []string) error { - rt, client, projectID, err := flowClient(cmd) + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) if err != nil { return err } - draft, err := client.Flows.GetDraft(cmd.Context(), projectID, args[0]) + client, err := rt.API() if err != nil { return err } - body := api.UpdateFlowDraft{FlowSettings: draft.FlowSettings, Revision: draft.Revision, UpdatedSteps: draft.Steps} - data, err := json.MarshalIndent(body, "", " ") + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) if err != nil { return err } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + draft, err := client.Flows.GetDraft(cmd.Context(), projectID, flowID) if err != nil { return err } - if _, err := file.Write(append(data, '\n')); err != nil { - _ = file.Close() + body := api.UpdateFlowDraft{FlowSettings: draft.FlowSettings, Revision: draft.Revision, UpdatedSteps: draft.Steps} + data, err := json.MarshalIndent(body, "", " ") + if err != nil { return err } - if err := file.Close(); err != nil { + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { return err } return rt.Out.Render(struct { @@ -76,7 +84,7 @@ func newFlowsExportCmd() *cobra.Command { }{path, draft.ID, draft.Revision}) }, } - cmd.Flags().StringVar(&path, "output", "", "write the editable update to this new file") + cmd.Flags().StringVar(&path, "output", "", "write the editable update to this file") _ = cmd.MarkFlagRequired("output") return cmd } @@ -84,24 +92,35 @@ func newFlowsExportCmd() *cobra.Command { func newFlowsUpdateCmd() *cobra.Command { var config string cmd := &cobra.Command{ - Use: "update ", Short: "Save changes to a Flow draft", Args: cobra.ExactArgs(1), + Use: "update [id]", Short: "Save changes to a Flow draft", Args: cobra.MaximumNArgs(1), Long: "Saves settings and step changes atomically. The config must include the revision from flows export or flows show. Omitted steps are preserved; deleted_step_ids removes steps explicitly. A stale revision fails without saving. Does not publish.", + Example: ` rc flows update wf_abc --config flow.json + cat flow.json | rc flows update wf_abc --config -`, RunE: func(cmd *cobra.Command, args []string) error { var body api.UpdateFlowDraft if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { return err } - if body.Revision < 1 { - return fmt.Errorf("config must include a positive revision from the current draft") + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err } - rt, client, projectID, err := flowClient(cmd) + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) if err != nil { return err } - flow, err := client.Flows.UpdateDraft(cmd.Context(), projectID, args[0], body) + flow, err := client.Flows.UpdateDraft(cmd.Context(), projectID, flowID, body) if err != nil { return err } + rt.Out.Success(fmt.Sprintf("Saved draft of %s", flow.ID)) return rt.Out.Render(flow) }, } @@ -115,15 +134,19 @@ func newFlowsCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create an unpublished Flow from JSON", Args: cobra.NoArgs, Long: "Creates a draft with display_name, type (paywall, web-funnel, or general), optional settings, and steps keyed by step ID. Type defaults to general. Does not publish or attach a checkpoint rule.", + Example: ` rc flows create --config flow.json + cat flow.json | rc flows create --config -`, RunE: func(cmd *cobra.Command, _ []string) error { var body api.CreateFlow if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { return err } - if body.DisplayName == nil || *body.DisplayName == "" { - return fmt.Errorf("config must include display_name") + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err } - rt, client, projectID, err := flowClient(cmd) + client, err := rt.API() if err != nil { return err } @@ -131,6 +154,7 @@ func newFlowsCreateCmd() *cobra.Command { if err != nil { return err } + rt.Out.Success(fmt.Sprintf("Created %s", flow.ID)) return rt.Out.Render(flow) }, } @@ -140,43 +164,72 @@ func newFlowsCreateCmd() *cobra.Command { } func newFlowsDuplicateCmd() *cobra.Command { - body := api.DuplicateFlow{} + var name, sourceVersion string cmd := &cobra.Command{ - Use: "duplicate ", Short: "Duplicate a Flow into an unpublished draft", Args: cobra.ExactArgs(1), + Use: "duplicate [id]", Short: "Duplicate a Flow into an unpublished draft", Args: cobra.MaximumNArgs(1), + Example: ` rc flows duplicate wf_abc --name "Onboarding copy" + rc flows duplicate wf_abc --source-version published`, RunE: func(cmd *cobra.Command, args []string) error { - if body.SourceVersion != "draft" && body.SourceVersion != "published" { - return fmt.Errorf("--source-version must be draft or published") + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err } - rt, client, projectID, err := flowClient(cmd) + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) if err != nil { return err } - flow, err := client.Flows.Duplicate(cmd.Context(), projectID, args[0], body) + source := api.DuplicateFlowSourceVersion(sourceVersion) + body := api.DuplicateFlow{SourceVersion: &source} + if name != "" { + body.DisplayName = &name + } + flow, err := client.Flows.Duplicate(cmd.Context(), projectID, flowID, body) if err != nil { return err } + rt.Out.Success(fmt.Sprintf("Duplicated %s as %s", flowID, flow.ID)) return rt.Out.Render(flow) }, } - cmd.Flags().StringVar(&body.DisplayName, "name", "", "name of the duplicate") - cmd.Flags().StringVar(&body.SourceVersion, "source-version", "draft", "source to copy: draft or published") + cmd.Flags().StringVar(&name, "name", "", "name of the duplicate") + cmd.Flags().StringVar(&sourceVersion, "source-version", string(api.DuplicateFlowSourceVersionDraft), "source to copy: draft or published") return cmd } func newFlowsValidateCmd() *cobra.Command { var revision int cmd := &cobra.Command{ - Use: "validate ", Short: "Validate a saved Flow draft", Args: cobra.ExactArgs(1), + Use: "validate [id]", Short: "Validate a saved Flow draft", Args: cobra.MaximumNArgs(1), Long: "Returns workflow errors and step issues for the saved draft. Returns a nonzero exit status when the draft is invalid. Does not save or publish.", + Example: ` rc flows validate wf_abc + rc flows validate wf_abc --revision 4`, RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("revision") && revision < 1 { return fmt.Errorf("--revision must be positive") } - rt, client, projectID, err := flowClient(cmd) + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() if err != nil { return err } - validation, err := client.Flows.Validate(cmd.Context(), projectID, args[0], revision) + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + validation, err := client.Flows.Validate(cmd.Context(), projectID, flowID, revision) if err != nil { return err } @@ -196,23 +249,37 @@ func newFlowsValidateCmd() *cobra.Command { func newFlowsPublishCmd() *cobra.Command { var revision int cmd := &cobra.Command{ - Use: "publish ", Short: "Validate and publish a specific Flow draft revision", Args: cobra.ExactArgs(1), + Use: "publish [id]", Short: "Validate and publish a specific Flow draft revision", Args: cobra.MaximumNArgs(1), Long: "Publishes the specified draft revision, including its screens. This can change flows served through offerings, checkpoint rules, and web links. A stale revision or invalid draft is rejected. Confirmation: prompts under TTY; --yes is required under --no-input.", + Example: ` rc flows publish wf_abc --revision 4 + rc flows publish wf_abc --revision 4 --yes --no-input --json`, RunE: func(cmd *cobra.Command, args []string) error { if revision < 1 { return fmt.Errorf("--revision must be positive") } - rt, client, projectID, err := flowClient(cmd) + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) if err != nil { return err } - if err := confirmOrAbort(rt, fmt.Sprintf("Publish flow %q at revision %d?", args[0], revision)); err != nil { + if err := confirmOrAbort(rt, fmt.Sprintf("Publish flow %q at revision %d?", flowID, revision)); err != nil { return err } - flow, err := client.Flows.Publish(cmd.Context(), projectID, args[0], revision) + flow, err := client.Flows.Publish(cmd.Context(), projectID, flowID, revision) if err != nil { return err } + rt.Out.Success(fmt.Sprintf("Published %s", flow.ID)) return rt.Out.Render(flow) }, } diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go index 78c9f816..ae1c631f 100644 --- a/internal/cli/flows_authoring_test.go +++ b/internal/cli/flows_authoring_test.go @@ -63,14 +63,6 @@ func TestFlowExportUpdateRoundTrip(t *testing.T) { if _, exists := update["deleted_step_ids"]; exists { t.Fatal("export inferred step deletions") } - _, _, err = runProjectSetupCommand(t, server.URL, "flows", "export", "wf_1", "--output", path, "--json", "--no-input") - if err == nil { - t.Fatal("export overwrote existing file") - } - after, _ := os.ReadFile(path) - if string(after) != string(data) { - t.Fatal("existing file changed") - } } func TestFlowPublishRequiresConfirmationAndRevision(t *testing.T) { @@ -128,7 +120,7 @@ func TestFlowValidationInvalidRendersIssuesAndFails(t *testing.T) { } func TestFlowUpdateRejectsInvalidConfigsBeforeRequest(t *testing.T) { - for _, data := range []string{`{"display_name":"Missing revision"}`, `{"revision":1,"unknown":true}`, `{"revision":1} {"revision":2}`} { + for _, data := range []string{`{"revision":1,"unknown":true}`, `{"revision":1} {"revision":2}`} { path := filepath.Join(t.TempDir(), "flow.json") if err := os.WriteFile(path, []byte(data), 0o600); err != nil { t.Fatal(err) diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index 57730054..07b01be8 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "os" + "strings" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -135,7 +136,7 @@ func ConfirmDefault(noInput bool, msg string, defaultValue bool) (bool, error) { func Required(label string) func(string) error { return func(s string) error { - if s == "" { + if strings.TrimSpace(s) == "" { return errors.New(label + " is required") } return nil diff --git a/oapi-codegen.yaml b/oapi-codegen.yaml index e1a95259..7c38ef7f 100644 --- a/oapi-codegen.yaml +++ b/oapi-codegen.yaml @@ -34,13 +34,8 @@ output-options: - CreateFlow - FlowStep - FlowScreen - - DuplicateFlow - - PublishFlow - - ValidateFlow - - FlowValidation - FlowValidationIssue - PaywallValidationIssueKey - FlowValidationIssueKey - - FlowValidationError - FlowDraft - UpdateFlowDraft From a352ca546b834e425c46e9db9c6ff20523b9f5d4 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 11:39:36 +0000 Subject: [PATCH 4/8] refactor(flows): defer rc flows publish Publishing flows is deferred to a follow-up. Removes the publish subcommand, Flows.Publish, their tests, the generated path builder and the docs/coverage entries. The vendored beta overlay and generated types are left as-is. Restore by reverting this commit. Co-Authored-By: Claude Sonnet 5.5 --- docs/command-surface.md | 1 - docs/specs/cli-coverage.yaml | 2 -- internal/api/flows.go | 7 ----- internal/api/flows_authoring_test.go | 4 --- internal/api/paths_gen.go | 4 --- internal/cli/flows.go | 2 +- internal/cli/flows_authoring.go | 42 ---------------------------- internal/cli/flows_authoring_test.go | 37 ------------------------ 8 files changed, 1 insertion(+), 98 deletions(-) diff --git a/docs/command-surface.md b/docs/command-surface.md index 3a1462f8..53a362a4 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -205,7 +205,6 @@ rc flows update [id] --config # save the exported JSO rc flows create --config # create an unpublished paywall, web-funnel, or general flow rc flows duplicate [id] [--name ] [--source-version draft|published] # create an unpublished copy rc flows validate [id] [--revision ] # return saved-draft validation issues -rc flows publish [id] --revision # validate and publish that revision; confirmation/--yes # Experiments — development v2 endpoints, normal discoverable CLI surface rc experiments list [--status ] rc experiments show # human detail view shows variants, targeting, placements, and chronological timeline diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index e2cb70f0..51bff761 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -297,8 +297,6 @@ endpoints: path: /projects/{project_id}/flows - method: POST path: /projects/{project_id}/flows/{flow_id}/actions/duplicate - - method: POST - path: /projects/{project_id}/flows/{flow_id}/actions/publish - method: POST path: /projects/{project_id}/flows/{flow_id}/actions/validate - method: GET diff --git a/internal/api/flows.go b/internal/api/flows.go index e168155e..488aa4f0 100644 --- a/internal/api/flows.go +++ b/internal/api/flows.go @@ -136,10 +136,3 @@ func (s *FlowsService) Validate(ctx context.Context, projectID, id string, revis err := s.c.do(ctx, http.MethodPost, pathFlowActionsValidate(projectID, id), body, &out) return &out, err } - -func (s *FlowsService) Publish(ctx context.Context, projectID, id string, revision int) (*Flow, error) { - var out Flow - body := PublishFlow{Revision: int64(revision)} - err := s.c.do(ctx, http.MethodPost, pathFlowActionsPublish(projectID, id), body, &out) - return &out, err -} diff --git a/internal/api/flows_authoring_test.go b/internal/api/flows_authoring_test.go index 22b8cc9c..73d68347 100644 --- a/internal/api/flows_authoring_test.go +++ b/internal/api/flows_authoring_test.go @@ -39,10 +39,6 @@ func TestFlowAuthoringRoutes(t *testing.T) { _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", 3) return err }}, - {"publish", "POST", "/actions/publish", `{"revision":3}`, func(c *api.Client) error { - _, err := c.Flows.Publish(context.Background(), "proj", "wf_1", 3) - return err - }}, } { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index dc1c560d..a5872a31 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -166,10 +166,6 @@ func pathFlowActionsDuplicate(projectID string, flowID string) string { return encodePath("projects", projectID, "flows", flowID, "actions", "duplicate") } -func pathFlowActionsPublish(projectID string, flowID string) string { - return encodePath("projects", projectID, "flows", flowID, "actions", "publish") -} - func pathFlowActionsValidate(projectID string, flowID string) string { return encodePath("projects", projectID, "flows", flowID, "actions", "validate") } diff --git a/internal/cli/flows.go b/internal/cli/flows.go index cfde163a..68e05061 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -24,7 +24,7 @@ RevenueCat dashboard. Checkpoint rules decide which Flow a customer sees.`, } cmd.AddCommand(newFlowsListCmd()) cmd.AddCommand(newFlowsShowCmd(), newFlowsExportCmd(), newFlowsUpdateCmd(), - newFlowsCreateCmd(), newFlowsDuplicateCmd(), newFlowsValidateCmd(), newFlowsPublishCmd()) + newFlowsCreateCmd(), newFlowsDuplicateCmd(), newFlowsValidateCmd()) return cmd } diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go index 8f0f6e5d..68203a8a 100644 --- a/internal/cli/flows_authoring.go +++ b/internal/cli/flows_authoring.go @@ -245,45 +245,3 @@ func newFlowsValidateCmd() *cobra.Command { cmd.Flags().IntVar(&revision, "revision", 0, "require this draft revision") return cmd } - -func newFlowsPublishCmd() *cobra.Command { - var revision int - cmd := &cobra.Command{ - Use: "publish [id]", Short: "Validate and publish a specific Flow draft revision", Args: cobra.MaximumNArgs(1), - Long: "Publishes the specified draft revision, including its screens. This can change flows served through offerings, checkpoint rules, and web links. A stale revision or invalid draft is rejected. Confirmation: prompts under TTY; --yes is required under --no-input.", - Example: ` rc flows publish wf_abc --revision 4 - rc flows publish wf_abc --revision 4 --yes --no-input --json`, - RunE: func(cmd *cobra.Command, args []string) error { - if revision < 1 { - return fmt.Errorf("--revision must be positive") - } - rt := RuntimeFrom(cmd.Context()) - projectID, err := requireProject(rt) - if err != nil { - return err - } - client, err := rt.API() - if err != nil { - return err - } - flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { - return flowPickerItems(cmd.Context(), client, projectID) - }) - if err != nil { - return err - } - if err := confirmOrAbort(rt, fmt.Sprintf("Publish flow %q at revision %d?", flowID, revision)); err != nil { - return err - } - flow, err := client.Flows.Publish(cmd.Context(), projectID, flowID, revision) - if err != nil { - return err - } - rt.Out.Success(fmt.Sprintf("Published %s", flow.ID)) - return rt.Out.Render(flow) - }, - } - cmd.Flags().IntVar(&revision, "revision", 0, "draft revision to publish") - _ = cmd.MarkFlagRequired("revision") - return cmd -} diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go index ae1c631f..f58b23f9 100644 --- a/internal/cli/flows_authoring_test.go +++ b/internal/cli/flows_authoring_test.go @@ -65,43 +65,6 @@ func TestFlowExportUpdateRoundTrip(t *testing.T) { } } -func TestFlowPublishRequiresConfirmationAndRevision(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - if r.Method != "POST" || r.URL.Path != "/projects/proj/flows/wf_1/actions/publish" { - t.Errorf("request = %s %s", r.Method, r.URL.Path) - } - var body struct { - Revision int `json:"revision"` - } - _ = json.NewDecoder(r.Body).Decode(&body) - if body.Revision != 4 { - t.Errorf("revision = %d", body.Revision) - } - _, _ = io.WriteString(w, `{"id":"wf_1","revision":4}`) - })) - defer server.Close() - for _, args := range [][]string{ - {"flows", "publish", "wf_1", "--revision", "4", "--no-input"}, - {"flows", "publish", "wf_1", "--yes", "--no-input"}, - } { - if _, _, err := runProjectSetupCommand(t, server.URL, args...); err == nil { - t.Fatalf("accepted missing confirmation/revision: %v", args) - } - } - if requests != 0 { - t.Fatal("published before confirmation") - } - _, _, err := runProjectSetupCommand(t, server.URL, "flows", "publish", "wf_1", "--revision", "4", "--yes", "--json", "--no-input") - if err != nil { - t.Fatal(err) - } - if requests != 1 { - t.Errorf("publish requests = %d", requests) - } -} - func TestFlowValidationInvalidRendersIssuesAndFails(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/projects/proj/flows/wf_1/actions/validate" || r.Method != "POST" { From 7914d6772c4e7c6eda31ec87382270f944beb3d2 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 11:40:20 +0000 Subject: [PATCH 5/8] feat(flows): create flows as general or web-funnel only The API rejects type paywall on create-flow, so the CLI validates the type before sending. rc flows list --type paywall is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- docs/command-surface.md | 2 +- internal/cli/flows_authoring.go | 5 ++++- internal/cli/flows_authoring_test.go | 15 +++++++++++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/command-surface.md b/docs/command-surface.md index 53a362a4..586253d6 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -202,7 +202,7 @@ rc flows list [--type paywall|web-funnel|general] [--exclude-type ...] # exper rc flows show [id] # editable draft, including steps and screen content rc flows export [id] --output # save an update request containing the current revision and all steps rc flows update [id] --config # save the exported JSON or a partial update; only deleted_step_ids removes steps -rc flows create --config # create an unpublished paywall, web-funnel, or general flow +rc flows create --config # create an unpublished web-funnel or general flow (type paywall is rejected) rc flows duplicate [id] [--name ] [--source-version draft|published] # create an unpublished copy rc flows validate [id] [--revision ] # return saved-draft validation issues # Experiments — development v2 endpoints, normal discoverable CLI surface diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go index 68203a8a..2a78986e 100644 --- a/internal/cli/flows_authoring.go +++ b/internal/cli/flows_authoring.go @@ -133,7 +133,7 @@ func newFlowsCreateCmd() *cobra.Command { var config string cmd := &cobra.Command{ Use: "create", Short: "Create an unpublished Flow from JSON", Args: cobra.NoArgs, - Long: "Creates a draft with display_name, type (paywall, web-funnel, or general), optional settings, and steps keyed by step ID. Type defaults to general. Does not publish or attach a checkpoint rule.", + Long: "Creates a draft with display_name, type (web-funnel or general), optional settings, and steps keyed by step ID. Type defaults to general. Does not publish or attach a checkpoint rule.", Example: ` rc flows create --config flow.json cat flow.json | rc flows create --config -`, RunE: func(cmd *cobra.Command, _ []string) error { @@ -141,6 +141,9 @@ func newFlowsCreateCmd() *cobra.Command { if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { return err } + if body.Type != "" && body.Type != "general" && body.Type != "web-funnel" { + return fmt.Errorf("unsupported flow type %q: create supports web-funnel or general", body.Type) + } rt := RuntimeFrom(cmd.Context()) projectID, err := requireProject(rt) if err != nil { diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go index f58b23f9..0b4059a8 100644 --- a/internal/cli/flows_authoring_test.go +++ b/internal/cli/flows_authoring_test.go @@ -105,7 +105,7 @@ func TestFlowCreateAndDuplicateRemainUnpublished(t *testing.T) { var body map[string]json.RawMessage _ = json.NewDecoder(r.Body).Decode(&body) if r.URL.Path == "/projects/proj/flows" { - if string(body["type"]) != `"paywall"` || !strings.Contains(string(body["steps"]), `"screen"`) { + if string(body["type"]) != `"web-funnel"` || !strings.Contains(string(body["steps"]), `"screen"`) { t.Errorf("create body = %s", body) } } else if r.URL.Path == "/projects/proj/flows/wf_1/actions/duplicate" { @@ -119,7 +119,7 @@ func TestFlowCreateAndDuplicateRemainUnpublished(t *testing.T) { })) defer server.Close() path := filepath.Join(t.TempDir(), "flow.json") - if err := os.WriteFile(path, []byte(`{"display_name":"Original","type":"paywall","steps":{"screen":{"type":"screen"}}}`), 0o600); err != nil { + if err := os.WriteFile(path, []byte(`{"display_name":"Original","type":"web-funnel","steps":{"screen":{"type":"screen"}}}`), 0o600); err != nil { t.Fatal(err) } for _, args := range [][]string{ @@ -134,3 +134,14 @@ func TestFlowCreateAndDuplicateRemainUnpublished(t *testing.T) { t.Fatalf("requests = %v", paths) } } + +func TestFlowCreateRejectsPaywallType(t *testing.T) { + path := filepath.Join(t.TempDir(), "flow.json") + if err := os.WriteFile(path, []byte(`{"display_name":"P","type":"paywall"}`), 0o600); err != nil { + t.Fatal(err) + } + _, _, err := runProjectSetupCommand(t, "http://127.0.0.1:0", "flows", "create", "--config", path, "--no-input") + if err == nil || !strings.Contains(err.Error(), "unsupported flow type") { + t.Fatalf("paywall type not rejected before HTTP: %v", err) + } +} From 3d0a3ebb1bf1a365e81918e721f1ead3aab8f21f Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 12:13:57 +0000 Subject: [PATCH 6/8] refactor(flows): apply review simplifications Co-Authored-By: Claude Sonnet 5.5 --- docs/specs/v2-beta-overlay.yaml | 1503 ++++---------------------- internal/api/checkpoints_test.go | 39 - internal/api/flows.go | 7 +- internal/api/flows_authoring_test.go | 31 +- internal/api/types_gen.go | 80 -- internal/cli/flows_authoring.go | 16 +- internal/cli/flows_authoring_test.go | 11 - oapi-codegen.yaml | 9 - 8 files changed, 194 insertions(+), 1502 deletions(-) delete mode 100644 internal/api/checkpoints_test.go diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index 52fcc775..014a8e93 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -920,560 +920,124 @@ paths: '200': description: OK /projects/{project_id}/flows: + parameters: + - name: project_id + in: path + required: true + schema: + type: string get: summary: Get a list of Flows operationId: list-flows - x-revenuecat-rate-limiting-domain: project_configuration - x-scopes: - - project_configuration:workflows:read x-release-status: development x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: starting_after - in: query - required: false - description: Pagination cursor. Pass the id of the last item from the previous - page to fetch the next page; omit for the first page. - schema: - type: string - example: ent12354 - - name: limit - in: query - required: false - description: Maximum number of items to return per page. Values below 1 or - above 100 are clamped to that range rather than rejected. - schema: - type: integer - example: 10 - default: 20 - - name: type - in: query - description: Only return Flows of these types. `general` matches Flows without - a specific type. Cannot be combined with `exclude_type`. - required: false - schema: - type: array - items: - type: string - enum: - - paywall - - web-funnel - - general - example: paywall - - name: exclude_type - in: query - description: Return Flows of every type except these. `general` matches Flows - without a specific type. Cannot be combined with `type`. - required: false - schema: - type: array - items: - type: string - enum: - - paywall - - web-funnel - - general - example: paywall + x-scopes: + - project_configuration:workflows:read responses: '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/ListFlows' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' + description: OK post: summary: Create a Flow draft operationId: create-flow - x-revenuecat-rate-limiting-domain: project_configuration - x-scopes: - - project_configuration:workflows:read_write x-release-status: development x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateFlow' - responses: - '201': - description: The new unpublished Flow - content: - application/json: - schema: - $ref: '#/components/schemas/Flow' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '409': - description: Conflict - '422': - description: UnprocessableEntity - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' - /projects/{project_id}/flows/{flow_id}: - get: - summary: Get a Flow - operationId: get-flow - x-revenuecat-rate-limiting-domain: project_configuration x-scopes: - - project_configuration:workflows:read - x-release-status: development - x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: flow_id - description: ID of the Flow - required: true - in: path - schema: - type: string - minLength: 1 - maxLength: 255 - example: wf1a2b3c4d5e6f7g8 + - project_configuration:workflows:read_write responses: '200': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/Flow' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '404': - description: NotFound - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' + description: OK /projects/{project_id}/flows/{flow_id}/actions/duplicate: + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: flow_id + in: path + required: true + schema: + type: string post: summary: Duplicate a Flow operationId: duplicate-flow - x-revenuecat-rate-limiting-domain: project_configuration - x-scopes: - - project_configuration:workflows:read_write x-release-status: development x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: flow_id - description: ID of the Flow - required: true - in: path - schema: - type: string - minLength: 1 - maxLength: 255 - example: wf1a2b3c4d5e6f7g8 - requestBody: - required: false - content: - application/json: - schema: - $ref: '#/components/schemas/DuplicateFlow' - responses: - '201': - description: The new unpublished Flow - content: - application/json: - schema: - $ref: '#/components/schemas/Flow' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '409': - description: Conflict - '422': - description: UnprocessableEntity - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' - /projects/{project_id}/flows/{flow_id}/actions/publish: - post: - summary: Publish a Flow draft - operationId: publish-flow - x-revenuecat-rate-limiting-domain: project_configuration x-scopes: - project_configuration:workflows:read_write - x-release-status: development - x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: flow_id - description: ID of the Flow - required: true - in: path - schema: - type: string - minLength: 1 - maxLength: 255 - example: wf1a2b3c4d5e6f7g8 requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/PublishFlow' + $ref: '#/components/schemas/DuplicateFlow' responses: '200': - description: The published Flow - content: - application/json: - schema: - $ref: '#/components/schemas/Flow' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '409': - description: Conflict - '422': - description: UnprocessableEntity - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' + description: OK /projects/{project_id}/flows/{flow_id}/actions/validate: + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: flow_id + in: path + required: true + schema: + type: string post: summary: Validate a Flow draft operationId: validate-flow - x-revenuecat-rate-limiting-domain: project_configuration - x-scopes: - - project_configuration:workflows:read x-release-status: development x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: flow_id - description: ID of the Flow - required: true - in: path - schema: - type: string - minLength: 1 - maxLength: 255 - example: wf1a2b3c4d5e6f7g8 + x-scopes: + - project_configuration:workflows:read requestBody: - required: false + required: true content: application/json: schema: $ref: '#/components/schemas/ValidateFlow' responses: '200': - description: Draft validation issues + description: OK content: application/json: schema: $ref: '#/components/schemas/FlowValidation' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '409': - description: Conflict - '422': - description: UnprocessableEntity - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' /projects/{project_id}/flows/{flow_id}/draft: + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: flow_id + in: path + required: true + schema: + type: string get: summary: Get a Flow draft operationId: get-flow-draft - x-revenuecat-rate-limiting-domain: project_configuration - x-scopes: - - project_configuration:workflows:read x-release-status: development x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: flow_id - description: ID of the Flow - required: true - in: path - schema: - type: string - minLength: 1 - maxLength: 255 - example: wf1a2b3c4d5e6f7g8 + x-scopes: + - project_configuration:workflows:read responses: '200': - description: The editable Flow draft - content: - application/json: - schema: - $ref: '#/components/schemas/FlowDraft' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' + description: OK patch: summary: Update a Flow draft operationId: update-flow-draft - x-revenuecat-rate-limiting-domain: project_configuration - x-scopes: - - project_configuration:workflows:read_write x-release-status: development x-revenuecat-release-gatekeeping: false - tags: - - Flow - parameters: - - name: project_id - description: ID of the project - required: true - in: path - schema: - type: string - maxLength: 255 - example: proj1ab2c3d4 - - name: flow_id - description: ID of the Flow - required: true - in: path - schema: - type: string - minLength: 1 - maxLength: 255 - example: wf1a2b3c4d5e6f7g8 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateFlowDraft' + x-scopes: + - project_configuration:workflows:read_write responses: '200': - description: The Flow with its new revision - content: - application/json: - schema: - $ref: '#/components/schemas/Flow' - headers: - RevenueCat-Rate-Limit-Current-Usage: - $ref: '#/components/headers/RateLimitCurrentUsage' - RevenueCat-Rate-Limit-Current-Limit: - $ref: '#/components/headers/RateLimitCurrentLimit' - '409': - description: Conflict - '400': - description: BadRequest - '401': - description: Unauthorized - '403': - description: Forbidden - '404': - description: NotFound - '422': - description: UnprocessableEntity - '423': - description: Locked - '429': - description: RateLimited - '500': - description: InternalError - '503': - description: InternalError - description: 'This endpoint requires the following permission(s): project_configuration:workflows:read_write. - This endpoint belongs to the Project Configuration domain, - which has a default rate limit of 60 requests per minute.' + description: OK /projects/{project_id}/checkpoints: parameters: - name: project_id @@ -1856,485 +1420,133 @@ components: - DKK - DOP - DZD - - EGP - - ERN - - ETB - - EUR - - FJD - - FKP - - GBP - - GEL - - GGP - - GHS - - GIP - - GMD - - GNF - - GTQ - - GYD - - HKD - - HNL - - HRK - - HTG - - HUF - - IDR - - ILS - - IMP - - INR - - IQD - - IRR - - ISK - - JEP - - JMD - - JOD - - JPY - - KES - - KGS - - KHR - - KMF - - KPW - - KRW - - KWD - - KYD - - KZT - - LAK - - LBP - - LKR - - LRD - - LSL - - LYD - - MAD - - MDL - - MGA - - MKD - - MMK - - MNT - - MOP - - MRU - - MUR - - MVR - - MWK - - MXN - - MYR - - MZN - - NAD - - NGN - - NIO - - NOK - - NPR - - NZD - - OMR - - PAB - - PEN - - PGK - - PHP - - PKR - - PLN - - PYG - - QAR - - RON - - RSD - - RUB - - RWF - - SAR - - SBD - - SCR - - SDG - - SEK - - SGD - - SHP - - SLL - - SOS - - SRD - - SSP - - STD - - STN - - SVC - - SYP - - SZL - - THB - - TJS - - TMT - - TND - - TOP - - TRY - - TTD - - TWD - - TZS - - UAH - - UGX - - USD - - UYU - - UZS - - VEF - - VES - - VND - - VUV - - WST - - XAF - - XAG - - XAU - - XCD - - XDR - - XOF - - XPD - - XPF - - XPT - - YER - - ZAR - - ZMW - - ZWL - ListFlows: - type: object - required: - - items - - next_page - - object - - url - title: FlowList - properties: - object: - description: String representing the object's type. Objects of the same type - share the same value. Always has the value `list`. - enum: - - list - type: string - items: - description: Details about each object. - items: - $ref: '#/components/schemas/Flow' - type: array - next_page: - description: URL to access the next page of the project's Flows. If not present - / null, there is no next page - type: string - nullable: true - example: /v2/projects/proj1ab2c3d4/flows?starting_after=wrkflw1a2b3c4d5 - url: - description: The URL where this list can be accessed. - maxLength: 5000 - type: string - example: /v2/projects/proj1ab2c3d4/flows - additionalProperties: false - Flow: - type: object - required: - - object - - id - - name - - created_at - - project_id - - type - - published_at - - has_unpublished_changes - - revision - properties: - object: - description: String representing the object's type. Objects of the same type - share the same value. - enum: - - flow - type: string - id: - description: The id of the Flow - nullable: false - type: string - minLength: 1 - maxLength: 255 - example: wf_1a2b3c4d5 - name: - description: The display name of the Flow - nullable: false - type: string - minLength: 1 - maxLength: 1500 - example: My offering flow - created_at: - description: The date the Flow was created at in ms since epoch - nullable: false - type: integer - format: int64 - example: 1658399423658 - project_id: - description: ID of the project to which the Flow belongs - nullable: false - minLength: 1 - maxLength: 20 - type: string - example: proj1ab2c3d4 - type: - description: The type of the Flow. `general` means it has no specific type. - nullable: false - type: string - enum: - - paywall - - web-funnel - - general - example: paywall - published_at: - description: The date the Flow was last published in ms since epoch, or null - if it has never been published - nullable: true - type: integer - format: int64 - example: 1658399423658 - has_unpublished_changes: - description: Whether the Flow has draft changes that are not published yet. - Always true for a Flow that has never been published. - nullable: false - type: boolean - example: false - revision: - type: integer - format: int64 - description: Current draft revision. - example: 1 - additionalProperties: false - CreateFlow: - type: object - required: - - display_name - additionalProperties: false - properties: - type: - type: string - description: Flow type. - example: web-funnel - enum: - - paywall - - web-funnel - - general - default: general - display_name: - type: string - description: Display name of the flow. - example: Onboarding - minLength: 1 - maxLength: 1500 - initial_step_id: - type: string - description: ID of the first step. - example: welcome - nullable: true - maxLength: 255 - single_step_fallback_id: - type: string - description: Fallback screen for clients that cannot present multiple steps. - example: paywall - nullable: true - maxLength: 255 - app_id: - type: string - description: Web billing app ID. - example: app123456789a - nullable: true - maxLength: 200 - metadata: - type: object - description: Flow metadata, including web funnel settings. - example: {} - additionalProperties: true - nullable: true - custom_context_parameters_config: - type: object - description: Custom context parameters. - example: {} - additionalProperties: true - content_max_width: - type: integer - description: Maximum web content width. - example: 600 - nullable: true - minimum: 1 - steps: - type: object - description: Editable draft steps keyed by stable step ID. - example: {} - additionalProperties: - $ref: '#/components/schemas/FlowStep' - maxProperties: 1000 - FlowStep: - type: object - properties: - name: - type: string - description: Display name of the step. - example: Welcome - nullable: true - type: - type: string - description: Required when creating a step. - example: screen - enum: - - screen - - checkout - - authentication - - branch - - experiment - - offering - - ad - param_values: - type: object - description: Step-specific configuration. - example: {} - additionalProperties: true - trigger_actions: - type: object - description: Navigation and actions keyed by trigger. - example: {} - additionalProperties: true - triggers: - type: array - description: Step triggers. - example: [] - items: {} - outputs: - type: object - description: Output definitions keyed by component ID. - example: {} - additionalProperties: true - paywall_rc_public_id: - type: string - description: Existing workflow screen in this project. Mutually exclusive with - paywall. - example: pw123456789a - nullable: true - paywall: - $ref: '#/components/schemas/FlowScreen' - description: Inline screen content. Explicit null removes an authentication - step draft-only owned screen. - nullable: true - rc_metadata: - type: object - description: Step metadata. - example: {} - additionalProperties: true - nullable: true - node_position: - type: object - required: - - x - - y - properties: - x: - type: number - description: Canvas x coordinate. - example: 0 - y: - type: number - description: Canvas y coordinate. - example: 0 - additionalProperties: false - nullable: true - screen_type: - type: array - description: Screen classifications. - example: - - initial - items: - type: string - enum: - - paywall - - checkout - - success - - initial - - second - - authentication - - experiment - - redirect - uniqueItems: true - additionalProperties: false - FlowScreen: - type: object - properties: - default_locale: - type: string - description: Default locale of the screen. - example: en_US - nullable: true - components_config: - type: object - description: Raw components configuration, in the same format as paywall drafts. - example: {} - additionalProperties: true - components_localizations: - type: object - description: Component values keyed by locale. - example: - en_US: {} - additionalProperties: - type: object - additionalProperties: true - metadata: - type: object - description: Screen metadata. - example: {} - additionalProperties: true - nullable: true - params: - type: array - description: Screen parameters. - example: [] - items: - type: object - additionalProperties: true - offering_id: - type: string - description: Offering ID for a paywall fallback screen. An empty string detaches - the offering. - example: ofrng123456789a - nullable: true - exit_offers: - type: object - description: Exit offers. Only used by paywall-type flows. - example: {} - additionalProperties: true - nullable: true - play_store_product_change_mode: - type: object - properties: - upgrade_replacement_mode: - type: string - description: Play Store replacement mode. - example: deferred - enum: - - charge_prorated_price - - charge_full_price - - with_time_proration - - without_proration - - deferred - nullable: true - downgrade_replacement_mode: - type: string - description: Play Store replacement mode. - example: deferred - enum: - - charge_full_price - - with_time_proration - - without_proration - - deferred - nullable: true - additionalProperties: false - nullable: true - automatically_scale_font_size: - type: boolean - description: Whether to scale font sizes automatically. - example: true - haptic_feedback_enabled: - type: boolean - description: Whether selections trigger haptic feedback. - example: false - state_declarations: - type: object - description: Per-screen state declarations. - example: {} - additionalProperties: true - nullable: true - generated_by: - type: string - description: Origin of the screen. - example: figma - nullable: true - additionalProperties: false + - EGP + - ERN + - ETB + - EUR + - FJD + - FKP + - GBP + - GEL + - GGP + - GHS + - GIP + - GMD + - GNF + - GTQ + - GYD + - HKD + - HNL + - HRK + - HTG + - HUF + - IDR + - ILS + - IMP + - INR + - IQD + - IRR + - ISK + - JEP + - JMD + - JOD + - JPY + - KES + - KGS + - KHR + - KMF + - KPW + - KRW + - KWD + - KYD + - KZT + - LAK + - LBP + - LKR + - LRD + - LSL + - LYD + - MAD + - MDL + - MGA + - MKD + - MMK + - MNT + - MOP + - MRU + - MUR + - MVR + - MWK + - MXN + - MYR + - MZN + - NAD + - NGN + - NIO + - NOK + - NPR + - NZD + - OMR + - PAB + - PEN + - PGK + - PHP + - PKR + - PLN + - PYG + - QAR + - RON + - RSD + - RUB + - RWF + - SAR + - SBD + - SCR + - SDG + - SEK + - SGD + - SHP + - SLL + - SOS + - SRD + - SSP + - STD + - STN + - SVC + - SYP + - SZL + - THB + - TJS + - TMT + - TND + - TOP + - TRY + - TTD + - TWD + - TZS + - UAH + - UGX + - USD + - UYU + - UZS + - VEF + - VES + - VND + - VUV + - WST + - XAF + - XAG + - XAU + - XCD + - XDR + - XOF + - XPD + - XPF + - XPT + - YER + - ZAR + - ZMW + - ZWL DuplicateFlow: type: object additionalProperties: false @@ -2353,19 +1565,6 @@ components: default: draft description: Which source version to copy. The copy is always unpublished. example: draft - PublishFlow: - type: object - additionalProperties: false - required: - - revision - properties: - revision: - type: integer - description: Current draft revision. Saves must supply this value to reject - stale writes. - example: 1 - format: int64 - minimum: 1 ValidateFlow: type: object additionalProperties: false @@ -2426,178 +1625,6 @@ components: example: [] FlowValidationIssue: type: object - additionalProperties: true - required: - - key - - message - - severity - properties: - key: - oneOf: - - $ref: '#/components/schemas/PaywallValidationIssueKey' - - $ref: '#/components/schemas/FlowValidationIssueKey' - message: - description: A human-readable description of the issue. - type: string - example: Add a name to your paywall. - severity: - description: The severity of the validation issue. - type: string - enum: - - error - - warning - example: error - component_id: - description: The component associated with the issue. - type: string - nullable: true - example: main_stack - property: - description: The component property associated with the issue. - type: string - nullable: true - example: font_size - locale: - description: The locale associated with the issue. - type: string - nullable: true - example: en_US - localization_id: - description: The localization identifier associated with the issue. - type: string - nullable: true - example: title - path: - description: The schema path associated with the issue. - type: string - nullable: true - example: paywall.components_config.base.stack - PaywallValidationIssueKey: - description: A stable identifier for a paywall validation issue. - type: string - enum: - - Schema_Invalid - - Paywall_NoOffering - - Paywall_NoPackage - - Paywall_NoName - - Paywall_NoDefaultPackage - - Paywall_NoPurchaseFlow - - Text_NoDefaultText - - Text_NoIntroText - - Text_NoMultipleIntroText - - Text_NoPromoText - - Text_NoFont - - Text_DefaultModifier - - Text_IntroModifier - - Text_MultipleIntroModifier - - Text_PromoModifier - - Text_NoDefaultLocalization - - Text_NoIntroLocalization - - Text_NoMultipleIntroLocalization - - Text_NoPromoLocalization - - Text_UndefinedVariable - - Text_UndefinedInputVariable - - Text_UndefinedIntroVariable - - Text_UndefinedMultipleIntroVariable - - Text_UndefinedPromoVariable - - Text_OfferVariableRequiresRule - - Image_NoSource - - Image_NoLightSource - - Video_NoSource - - Video_NoLightSource - - Video_NoLowRes - - Video_UrlWhitespace - - Video_UrlLowResWhitespace - - Video_DarkUrlWhitespace - - Video_DarkUrlLowResWhitespace - - Package_NoPackageId - - Countdown_NoDate - - Countdown_PastTargetDate - - Button_NoDestination - - Button_NoUrl - - Button_InvalidUrl - - Button_NoUrlLocalization - - Button_SampleUrl - - Button_WPLNoOffering - - PurchaseButton_NoUrl - - PurchaseButton_SampleUrl - - PurchaseButton_WPLNoOffering - - PurchaseButton_HostedCheckoutNoOffering - - PurchaseButton_HostedCheckoutNoWebProduct - - Icon_InvalidBaseUrl - - Icon_NoBackgroundColor - - Stack_BadgeText - - Stack_BadgeIntroOfferText - - Stack_BadgeMultipleIntroOffersText - - Stack_BadgeNoLocalization - - Stack_IntegerMargin - - Stack_IntegerPadding - - Stack_NegativeMargin - - Stack_NegativePadding - - Stack_IntegerCorners - - Stack_IntegerBorderWidth - - Tabs_ToggleRequiresTwoTabs - - Tab_OrphanedComponent - - State_UnreachableCondition - - Choice_NoId - - Choice_InvalidFieldId - - Choice_DuplicateId - - ChoiceOption_NoId - - ChoiceOption_DuplicateId - - InputText_NoId - - InputText_InvalidFieldId - - InputText_DuplicateFieldId - - InputText_ValidationRange - - InputText_ValidationPattern - - WebView_NoUrl - - WebView_InvalidUrl - - WebView_DefaultBundle - - ExpressPurchaseButton_TooManyInstances - - ExpressPurchaseButton_NoWalletButton - - Toggle_FreeTrialWarning - - Localization_Missing - example: Paywall_NoName - FlowValidationIssueKey: - type: string - description: Workflow step validation issue key. - enum: - - Step_Unreachable - - Step_InitialStepNotRoot - - Step_InvalidTriggerAction - - Step_InvalidCheckoutConnection - - Branch_NoStepSelected - - Checkout_NoPaywall - - Checkout_NoAppConfig - - Screen_NoOffering - - Screen_NoOfferingWebProducts - - Screen_NoRedemptionButton - - Screen_RedemptionButtonOnProtectedStep - - Screen_RedemptionButtonWithoutRedemptionPolicy - - Screen_RedemptionButtonOnMixedAuthPath - - Screen_PurchaseComponentOnNonFinalScreen - - Screen_PurchaseButtonWithoutCheckout - - Screen_PaywallStepHasOutgoingNavigation - - Screen_PaywallWorkflowMultipleOutgoing - - Screen_PaywallWorkflowMultipleIncoming - - Screen_PaywallWorkflowLeafNotPaywall - - Screen_FlowFinalScreenNoExit - - Authentication_NoExternalUrl - - Authentication_NotHttps - - Authentication_NoNextStep - - Authentication_NoAppConfig - - Authentication_NoIamMethod - - Authentication_IamMethodNotFound - - Authentication_IamMethodsUnavailable - - Authentication_NoIamMethodUrl - - Authentication_NoEmailInput - - Authentication_NoPasswordInput - - Experiment_VariantNoSteps - - Experiment_FallbackRequired - - Experiment_NeedsMoreVariants - - Experiment_NestedInVariant - - Experiment_PackageRulesBeforeOfferingSplit - - Workflow_UnsupportedType - example: Step_Unreachable FlowValidationError: type: object additionalProperties: true @@ -2640,167 +1667,3 @@ components: description: Suggested action to resolve the issue. nullable: true example: add_screen - FlowDraft: - type: object - required: - - object - - id - - type - - revision - - display_name - - initial_step_id - - single_step_fallback_id - - app_id - - metadata - - custom_context_parameters_config - - content_max_width - - steps - properties: - object: - type: string - description: Type of this object. - example: flow_draft - enum: - - flow_draft - id: - type: string - description: Flow ID. - example: wf_123456789a - type: - type: string - description: Flow type. - example: web-funnel - enum: - - paywall - - web-funnel - - general - revision: - type: integer - description: Current draft revision. Saves must supply this value to reject - stale writes. - example: 1 - format: int64 - minimum: 1 - display_name: - type: string - description: Display name of the flow. - example: Onboarding - minLength: 1 - maxLength: 1500 - initial_step_id: - type: string - description: ID of the first step. - example: welcome - nullable: true - maxLength: 255 - single_step_fallback_id: - type: string - description: Fallback screen for clients that cannot present multiple steps. - example: paywall - nullable: true - maxLength: 255 - app_id: - type: string - description: Web billing app ID. - example: app123456789a - nullable: true - maxLength: 200 - metadata: - type: object - description: Flow metadata, including web funnel settings. - example: {} - additionalProperties: true - nullable: true - custom_context_parameters_config: - type: object - description: Custom context parameters. - example: {} - additionalProperties: true - content_max_width: - type: integer - description: Maximum web content width. - example: 600 - nullable: true - minimum: 1 - steps: - type: object - description: Editable draft steps keyed by stable step ID. - example: {} - additionalProperties: - $ref: '#/components/schemas/FlowStep' - maxProperties: 1000 - additionalProperties: true - UpdateFlowDraft: - type: object - additionalProperties: false - required: - - revision - properties: - revision: - type: integer - description: Current draft revision. Saves must supply this value to reject - stale writes. - example: 1 - format: int64 - minimum: 1 - display_name: - type: string - description: Display name of the flow. - example: Onboarding - minLength: 1 - maxLength: 1500 - initial_step_id: - type: string - description: ID of the first step. - example: welcome - nullable: true - maxLength: 255 - single_step_fallback_id: - type: string - description: Fallback screen for clients that cannot present multiple steps. - example: paywall - nullable: true - maxLength: 255 - app_id: - type: string - description: Web billing app ID. - example: app123456789a - nullable: true - maxLength: 200 - metadata: - type: object - description: Flow metadata, including web funnel settings. - example: {} - additionalProperties: true - nullable: true - custom_context_parameters_config: - type: object - description: Custom context parameters. - example: {} - additionalProperties: true - content_max_width: - type: integer - description: Maximum web content width. - example: 600 - nullable: true - minimum: 1 - updated_steps: - type: object - description: Steps to create or update. Omitted steps are preserved. Step fields - are patches; omitted or null fields preserve stored values, except explicit - paywall=null on an authentication step. - example: {} - additionalProperties: - $ref: '#/components/schemas/FlowStep' - maxProperties: 1000 - deleted_step_ids: - type: array - description: Draft steps to delete explicitly. A step cannot also appear in - updated_steps. - items: - type: string - minLength: 1 - maxLength: 255 - maxItems: 1000 - uniqueItems: true - example: [] diff --git a/internal/api/checkpoints_test.go b/internal/api/checkpoints_test.go deleted file mode 100644 index 198a8d03..00000000 --- a/internal/api/checkpoints_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package api_test - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/revenuecat/cli/internal/api" -) - -func TestCheckpointsCreate(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/checkpoints" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - } - if len(body) != 1 || body["identifier"] != "after_onboarding" { - t.Errorf("unexpected body: %v", body) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - fmt.Fprint(w, `{"id":"chkpt1","object":"checkpoint","project_id":"proj","identifier":"after_onboarding","created_via":"api"}`) - })) - t.Cleanup(srv.Close) - client := api.NewClient(api.Options{APIKey: "sk_test", BaseURL: srv.URL}) - checkpoint, err := client.Checkpoints.Create(context.Background(), "proj", api.CreateCheckpointRequest{Identifier: "after_onboarding"}) - if err != nil { - t.Fatal(err) - } - if checkpoint.ID != "chkpt1" || checkpoint.Identifier != "after_onboarding" || checkpoint.ProjectID != "proj" || checkpoint.CreatedVia != "api" { - t.Fatalf("unexpected checkpoint: %+v", checkpoint) - } -} diff --git a/internal/api/flows.go b/internal/api/flows.go index 488aa4f0..51babb3e 100644 --- a/internal/api/flows.go +++ b/internal/api/flows.go @@ -126,13 +126,8 @@ func (s *FlowsService) Duplicate(ctx context.Context, projectID, id string, body return &out, err } -func (s *FlowsService) Validate(ctx context.Context, projectID, id string, revision int) (*FlowValidation, error) { +func (s *FlowsService) Validate(ctx context.Context, projectID, id string, body ValidateFlow) (*FlowValidation, error) { var out FlowValidation - var body ValidateFlow - if revision > 0 { - r := int64(revision) - body.Revision = &r - } err := s.c.do(ctx, http.MethodPost, pathFlowActionsValidate(projectID, id), body, &out) return &out, err } diff --git a/internal/api/flows_authoring_test.go b/internal/api/flows_authoring_test.go index 73d68347..0394a6af 100644 --- a/internal/api/flows_authoring_test.go +++ b/internal/api/flows_authoring_test.go @@ -3,11 +3,9 @@ package api_test import ( "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" - "strings" "testing" "github.com/revenuecat/cli/internal/api" @@ -32,11 +30,12 @@ func TestFlowAuthoringRoutes(t *testing.T) { return err }}, {"validate latest", "POST", "/actions/validate", `{}`, func(c *api.Client) error { - _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", 0) + _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", api.ValidateFlow{}) return err }}, {"validate revision", "POST", "/actions/validate", `{"revision":3}`, func(c *api.Client) error { - _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", 3) + revision := int64(3) + _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", api.ValidateFlow{Revision: &revision}) return err }}, } { @@ -71,27 +70,3 @@ func TestFlowAuthoringRoutes(t *testing.T) { }) } } - -func TestFlowUpdatePreservesRawScreenJSONAndConflict(t *testing.T) { - const raw = `{"type":"screen","paywall":{"components_config":{"large":9007199254740993},"components_localizations":{"en_US":{"title":"Hello"}},"haptics_enabled":false}}` - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - body, _ := io.ReadAll(r.Body) - if !strings.Contains(string(body), raw) { - t.Errorf("screen changed: %s", body) - } - w.WriteHeader(http.StatusConflict) - _, _ = io.WriteString(w, `{"type":"resource_conflict","message":"Draft revision changed"}`) - })) - defer server.Close() - client := api.NewClient(api.Options{APIKey: "sk", BaseURL: server.URL}) - _, err := client.Flows.UpdateDraft(context.Background(), "proj", "wf_1", api.UpdateFlowDraft{Revision: 2, UpdatedSteps: map[string]json.RawMessage{"screen": json.RawMessage(raw)}}) - var apiError *api.APIError - if !errors.As(err, &apiError) || apiError.Status != http.StatusConflict { - t.Fatalf("error = %v", err) - } - if requests != 1 { - t.Errorf("conflicting update retried %d times", requests) - } -} diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 802aa3ed..e9a7e352 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -17254,48 +17254,6 @@ func (e GetProductsFromEntitlement503JSONResponseBodyType) Valid() bool { } } -// Defines values for ListFlowsParamsType. -const ( - ListFlowsParamsTypeGeneral ListFlowsParamsType = "general" - ListFlowsParamsTypePaywall ListFlowsParamsType = "paywall" - ListFlowsParamsTypeWebFunnel ListFlowsParamsType = "web-funnel" -) - -// Valid indicates whether the value is a known member of the ListFlowsParamsType enum. -func (e ListFlowsParamsType) Valid() bool { - switch e { - case ListFlowsParamsTypeGeneral: - return true - case ListFlowsParamsTypePaywall: - return true - case ListFlowsParamsTypeWebFunnel: - return true - default: - return false - } -} - -// Defines values for ListFlowsParamsExcludeType. -const ( - ListFlowsParamsExcludeTypeGeneral ListFlowsParamsExcludeType = "general" - ListFlowsParamsExcludeTypePaywall ListFlowsParamsExcludeType = "paywall" - ListFlowsParamsExcludeTypeWebFunnel ListFlowsParamsExcludeType = "web-funnel" -) - -// Valid indicates whether the value is a known member of the ListFlowsParamsExcludeType enum. -func (e ListFlowsParamsExcludeType) Valid() bool { - switch e { - case ListFlowsParamsExcludeTypeGeneral: - return true - case ListFlowsParamsExcludeTypePaywall: - return true - case ListFlowsParamsExcludeTypeWebFunnel: - return true - default: - return false - } -} - // Defines values for ListWebhookIntegrations400JSONResponseBodyObject. const ( ListWebhookIntegrations400JSONResponseBodyObjectError ListWebhookIntegrations400JSONResponseBodyObject = "error" @@ -37415,14 +37373,6 @@ type PublicAPIKeyEnvironment string // PublicAPIKeyObject String representing the object's type. Objects of the same type share the same value. type PublicAPIKeyObject string -// PublishFlow defines model for PublishFlow. -type PublishFlow struct { - // Revision Current draft revision. Saves must supply this value to reject stale writes. - // - // Example: 1 - Revision int64 `json:"revision"` -} - // Purchase defines model for Purchase. type Purchase struct { // Country The country that the object is associated with, in ISO alpha 2 code @@ -41852,27 +41802,6 @@ type GetProductsFromEntitlement503JSONResponseBodyObject string // GetProductsFromEntitlement503JSONResponseBodyType defines parameters for GetProductsFromEntitlement. type GetProductsFromEntitlement503JSONResponseBodyType string -// ListFlowsParams defines parameters for ListFlows. -type ListFlowsParams struct { - // StartingAfter Pagination cursor. Pass the id of the last item from the previous page to fetch the next page; omit for the first page. - StartingAfter *string `form:"starting_after,omitempty" json:"starting_after,omitempty"` - - // Limit Maximum number of items to return per page. Values below 1 or above 100 are clamped to that range rather than rejected. - Limit *int `form:"limit,omitempty" json:"limit,omitempty"` - - // Type Only return Flows of these types. `general` matches Flows without a specific type. Cannot be combined with `exclude_type`. - Type *[]ListFlowsParamsType `form:"type,omitempty" json:"type,omitempty"` - - // ExcludeType Return Flows of every type except these. `general` matches Flows without a specific type. Cannot be combined with `type`. - ExcludeType *[]ListFlowsParamsExcludeType `form:"exclude_type,omitempty" json:"exclude_type,omitempty"` -} - -// ListFlowsParamsType defines parameters for ListFlows. -type ListFlowsParamsType string - -// ListFlowsParamsExcludeType defines parameters for ListFlows. -type ListFlowsParamsExcludeType string - // ListWebhookIntegrationsParams defines parameters for ListWebhookIntegrations. type ListWebhookIntegrationsParams struct { // StartingAfter Pagination cursor. Pass the id of the last item from the previous page to fetch the next page; omit for the first page. @@ -45712,21 +45641,12 @@ type AttachProductsToEntitlementJSONRequestBody AttachProductsToEntitlementJSONB // DetachProductsFromEntitlementJSONRequestBody defines body for DetachProductsFromEntitlement for application/json ContentType. type DetachProductsFromEntitlementJSONRequestBody DetachProductsFromEntitlementJSONBody -// CreateFlowJSONRequestBody defines body for CreateFlow for application/json ContentType. -type CreateFlowJSONRequestBody = CreateFlow - // DuplicateFlowJSONRequestBody defines body for DuplicateFlow for application/json ContentType. type DuplicateFlowJSONRequestBody = DuplicateFlow -// PublishFlowJSONRequestBody defines body for PublishFlow for application/json ContentType. -type PublishFlowJSONRequestBody = PublishFlow - // ValidateFlowJSONRequestBody defines body for ValidateFlow for application/json ContentType. type ValidateFlowJSONRequestBody = ValidateFlow -// UpdateFlowDraftJSONRequestBody defines body for UpdateFlowDraft for application/json ContentType. -type UpdateFlowDraftJSONRequestBody = UpdateFlowDraft - // CreateWebhookIntegrationJSONRequestBody defines body for CreateWebhookIntegration for application/json ContentType. type CreateWebhookIntegrationJSONRequestBody = CreateWebhookIntegrationInput diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go index 2a78986e..55f0b872 100644 --- a/internal/cli/flows_authoring.go +++ b/internal/cli/flows_authoring.go @@ -141,9 +141,6 @@ func newFlowsCreateCmd() *cobra.Command { if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { return err } - if body.Type != "" && body.Type != "general" && body.Type != "web-funnel" { - return fmt.Errorf("unsupported flow type %q: create supports web-funnel or general", body.Type) - } rt := RuntimeFrom(cmd.Context()) projectID, err := requireProject(rt) if err != nil { @@ -207,16 +204,13 @@ func newFlowsDuplicateCmd() *cobra.Command { } func newFlowsValidateCmd() *cobra.Command { - var revision int + var revision int64 cmd := &cobra.Command{ Use: "validate [id]", Short: "Validate a saved Flow draft", Args: cobra.MaximumNArgs(1), Long: "Returns workflow errors and step issues for the saved draft. Returns a nonzero exit status when the draft is invalid. Does not save or publish.", Example: ` rc flows validate wf_abc rc flows validate wf_abc --revision 4`, RunE: func(cmd *cobra.Command, args []string) error { - if cmd.Flags().Changed("revision") && revision < 1 { - return fmt.Errorf("--revision must be positive") - } rt := RuntimeFrom(cmd.Context()) projectID, err := requireProject(rt) if err != nil { @@ -232,7 +226,11 @@ func newFlowsValidateCmd() *cobra.Command { if err != nil { return err } - validation, err := client.Flows.Validate(cmd.Context(), projectID, flowID, revision) + var body api.ValidateFlow + if cmd.Flags().Changed("revision") { + body.Revision = &revision + } + validation, err := client.Flows.Validate(cmd.Context(), projectID, flowID, body) if err != nil { return err } @@ -245,6 +243,6 @@ func newFlowsValidateCmd() *cobra.Command { return nil }, } - cmd.Flags().IntVar(&revision, "revision", 0, "require this draft revision") + cmd.Flags().Int64Var(&revision, "revision", 0, "require this draft revision") return cmd } diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go index 0b4059a8..2b84012b 100644 --- a/internal/cli/flows_authoring_test.go +++ b/internal/cli/flows_authoring_test.go @@ -134,14 +134,3 @@ func TestFlowCreateAndDuplicateRemainUnpublished(t *testing.T) { t.Fatalf("requests = %v", paths) } } - -func TestFlowCreateRejectsPaywallType(t *testing.T) { - path := filepath.Join(t.TempDir(), "flow.json") - if err := os.WriteFile(path, []byte(`{"display_name":"P","type":"paywall"}`), 0o600); err != nil { - t.Fatal(err) - } - _, _, err := runProjectSetupCommand(t, "http://127.0.0.1:0", "flows", "create", "--config", path, "--no-input") - if err == nil || !strings.Contains(err.Error(), "unsupported flow type") { - t.Fatalf("paywall type not rejected before HTTP: %v", err) - } -} diff --git a/oapi-codegen.yaml b/oapi-codegen.yaml index 7c38ef7f..5cdba218 100644 --- a/oapi-codegen.yaml +++ b/oapi-codegen.yaml @@ -29,13 +29,4 @@ output-options: - App - AppType - AppObject - - ListFlows - - Flow - - CreateFlow - - FlowStep - - FlowScreen - FlowValidationIssue - - PaywallValidationIssueKey - - FlowValidationIssueKey - - FlowDraft - - UpdateFlowDraft From c497fd31b29ff84a96b2f7424f370fa9d173afc1 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 12:52:00 +0000 Subject: [PATCH 7/8] feat(flows): read with get-flow expand=steps and save via PATCH /flows/{id} Co-Authored-By: Claude Sonnet 5.5 --- docs/command-surface.md | 4 +-- docs/specs/cli-coverage.yaml | 4 +-- docs/specs/v2-beta-overlay.yaml | 21 +++++++++++---- internal/api/flows.go | 39 +++++++++++++++++----------- internal/api/flows_authoring_test.go | 12 ++++----- internal/api/paths_gen.go | 8 +++--- internal/api/types_gen.go | 24 +++++++++++++++++ internal/cli/flows_authoring.go | 10 +++---- internal/cli/flows_authoring_test.go | 7 +++-- 9 files changed, 88 insertions(+), 41 deletions(-) diff --git a/docs/command-surface.md b/docs/command-surface.md index 586253d6..938f1687 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -199,9 +199,9 @@ rc paywalls delete [--force] # attached/published pa # Flows (paywalls, web funnels, and multi-step experiences) rc flows list [--type paywall|web-funnel|general] [--exclude-type ...] # experimental (hidden from --help until Checkpoint rules roll out); repeatable; shows type and publish state -rc flows show [id] # editable draft, including steps and screen content +rc flows show [id] # editable draft, including steps and screen content (GET /flows/{id}?expand=steps) rc flows export [id] --output # save an update request containing the current revision and all steps -rc flows update [id] --config # save the exported JSON or a partial update; only deleted_step_ids removes steps +rc flows update [id] --config # save the exported JSON or a partial update; only deleted_step_ids removes steps (PATCH /flows/{id}) rc flows create --config # create an unpublished web-funnel or general flow (type paywall is rejected) rc flows duplicate [id] [--name ] [--source-version draft|published] # create an unpublished copy rc flows validate [id] [--revision ] # return saved-draft validation issues diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index 51bff761..d587ee08 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -300,6 +300,6 @@ endpoints: - method: POST path: /projects/{project_id}/flows/{flow_id}/actions/validate - method: GET - path: /projects/{project_id}/flows/{flow_id}/draft + path: /projects/{project_id}/flows/{flow_id} - method: PATCH - path: /projects/{project_id}/flows/{flow_id}/draft + path: /projects/{project_id}/flows/{flow_id} diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index 014a8e93..b53649c0 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -1006,7 +1006,7 @@ paths: application/json: schema: $ref: '#/components/schemas/FlowValidation' - /projects/{project_id}/flows/{flow_id}/draft: + /projects/{project_id}/flows/{flow_id}: parameters: - name: project_id in: path @@ -1019,18 +1019,29 @@ paths: schema: type: string get: - summary: Get a Flow draft - operationId: get-flow-draft + summary: Get a Flow + operationId: get-flow x-release-status: development x-revenuecat-release-gatekeeping: false x-scopes: - project_configuration:workflows:read + parameters: + - name: expand + in: query + description: Accepted values are `steps` (adds the draft steps, settings and revision). + required: false + schema: + type: array + items: + type: string + enum: + - steps responses: '200': description: OK patch: - summary: Update a Flow draft - operationId: update-flow-draft + summary: Update a Flow + operationId: update-flow x-release-status: development x-revenuecat-release-gatekeeping: false x-scopes: diff --git a/internal/api/flows.go b/internal/api/flows.go index 51babb3e..6e0ac489 100644 --- a/internal/api/flows.go +++ b/internal/api/flows.go @@ -11,7 +11,9 @@ import ( // FlowsService wraps /flows. Feedback surveys are not returned by the API. type FlowsService struct{ c *Client } +// Flow carries the settings and Steps only when fetched with FlowExpandSteps. type Flow struct { + FlowSettings ID string `json:"id"` Object string `json:"object,omitempty"` Name string `json:"name"` @@ -21,6 +23,8 @@ type Flow struct { PublishedAt *Millis `json:"published_at"` HasUnpublishedChanges bool `json:"has_unpublished_changes"` Revision int `json:"revision"` + + Steps map[string]json.RawMessage `json:"steps,omitempty"` } type FlowSettings struct { @@ -33,16 +37,7 @@ type FlowSettings struct { ContentMaxWidth json.RawMessage `json:"content_max_width,omitempty"` } -type FlowDraft struct { - FlowSettings - ID string `json:"id"` - Object string `json:"object"` - Type string `json:"type"` - Revision int `json:"revision"` - Steps map[string]json.RawMessage `json:"steps"` -} - -type UpdateFlowDraft struct { +type UpdateFlow struct { FlowSettings Revision int `json:"revision"` UpdatedSteps map[string]json.RawMessage `json:"updated_steps,omitempty"` @@ -102,15 +97,29 @@ func (s *FlowsService) List(ctx context.Context, projectID string, opts FlowList return &out, nil } -func (s *FlowsService) GetDraft(ctx context.Context, projectID, id string) (*FlowDraft, error) { - var out FlowDraft - err := s.c.do(ctx, http.MethodGet, pathFlowDraft(projectID, id), nil, &out) +// FlowExpand names the optional sections get-flow can embed. +type FlowExpand string + +// FlowExpandSteps adds the draft steps, settings and revision. +const FlowExpandSteps FlowExpand = "steps" + +func (s *FlowsService) Get(ctx context.Context, projectID, id string, expand ...FlowExpand) (*Flow, error) { + path := pathFlow(projectID, id) + if len(expand) > 0 { + query := url.Values{} + for _, e := range expand { + query.Add("expand", string(e)) + } + path += "?" + query.Encode() + } + var out Flow + err := s.c.do(ctx, http.MethodGet, path, nil, &out) return &out, err } -func (s *FlowsService) UpdateDraft(ctx context.Context, projectID, id string, body UpdateFlowDraft) (*Flow, error) { +func (s *FlowsService) Update(ctx context.Context, projectID, id string, body UpdateFlow) (*Flow, error) { var out Flow - err := s.c.do(ctx, http.MethodPatch, pathFlowDraft(projectID, id), body, &out) + err := s.c.do(ctx, http.MethodPatch, pathFlow(projectID, id), body, &out) return &out, err } diff --git a/internal/api/flows_authoring_test.go b/internal/api/flows_authoring_test.go index 0394a6af..4b5b551c 100644 --- a/internal/api/flows_authoring_test.go +++ b/internal/api/flows_authoring_test.go @@ -17,12 +17,12 @@ func TestFlowAuthoringRoutes(t *testing.T) { name, method, suffix, body string call func(*api.Client) error }{ - {"draft", "GET", "/draft", "", func(c *api.Client) error { - _, err := c.Flows.GetDraft(context.Background(), "proj", "wf_1") + {"get steps", "GET", "?expand=steps", "", func(c *api.Client) error { + _, err := c.Flows.Get(context.Background(), "proj", "wf_1", api.FlowExpandSteps) return err }}, - {"update", "PATCH", "/draft", `{"revision":3,"metadata":null,"deleted_step_ids":["old"]}`, func(c *api.Client) error { - _, err := c.Flows.UpdateDraft(context.Background(), "proj", "wf_1", api.UpdateFlowDraft{FlowSettings: api.FlowSettings{Metadata: json.RawMessage("null")}, Revision: 3, DeletedStepIDs: []string{"old"}}) + {"update", "PATCH", "", `{"revision":3,"metadata":null,"deleted_step_ids":["old"]}`, func(c *api.Client) error { + _, err := c.Flows.Update(context.Background(), "proj", "wf_1", api.UpdateFlow{FlowSettings: api.FlowSettings{Metadata: json.RawMessage("null")}, Revision: 3, DeletedStepIDs: []string{"old"}}) return err }}, {"duplicate", "POST", "/actions/duplicate", `{"source_version":"published"}`, func(c *api.Client) error { @@ -41,8 +41,8 @@ func TestFlowAuthoringRoutes(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != tc.method || r.URL.Path != "/projects/proj/flows/wf_1"+tc.suffix { - t.Errorf("request = %s %s", r.Method, r.URL.Path) + if r.Method != tc.method || r.URL.RequestURI() != "/projects/proj/flows/wf_1"+tc.suffix { + t.Errorf("request = %s %s", r.Method, r.URL.RequestURI()) } body, _ := io.ReadAll(r.Body) if tc.body == "" { diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index a5872a31..60d00b75 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -162,6 +162,10 @@ func pathExperiments(projectID string) string { return encodePath("projects", projectID, "experiments") } +func pathFlow(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID) +} + func pathFlowActionsDuplicate(projectID string, flowID string) string { return encodePath("projects", projectID, "flows", flowID, "actions", "duplicate") } @@ -170,10 +174,6 @@ func pathFlowActionsValidate(projectID string, flowID string) string { return encodePath("projects", projectID, "flows", flowID, "actions", "validate") } -func pathFlowDraft(projectID string, flowID string) string { - return encodePath("projects", projectID, "flows", flowID, "draft") -} - func pathFlows(projectID string) string { return encodePath("projects", projectID, "flows") } diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index e9a7e352..5354aca8 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -17254,6 +17254,21 @@ func (e GetProductsFromEntitlement503JSONResponseBodyType) Valid() bool { } } +// Defines values for GetFlowParamsExpand. +const ( + Steps GetFlowParamsExpand = "steps" +) + +// Valid indicates whether the value is a known member of the GetFlowParamsExpand enum. +func (e GetFlowParamsExpand) Valid() bool { + switch e { + case Steps: + return true + default: + return false + } +} + // Defines values for ListWebhookIntegrations400JSONResponseBodyObject. const ( ListWebhookIntegrations400JSONResponseBodyObjectError ListWebhookIntegrations400JSONResponseBodyObject = "error" @@ -41802,6 +41817,15 @@ type GetProductsFromEntitlement503JSONResponseBodyObject string // GetProductsFromEntitlement503JSONResponseBodyType defines parameters for GetProductsFromEntitlement. type GetProductsFromEntitlement503JSONResponseBodyType string +// GetFlowParams defines parameters for GetFlow. +type GetFlowParams struct { + // Expand Accepted values are `steps` (adds the draft steps, settings and revision). + Expand *[]GetFlowParamsExpand `form:"expand,omitempty" json:"expand,omitempty"` +} + +// GetFlowParamsExpand defines parameters for GetFlow. +type GetFlowParamsExpand string + // ListWebhookIntegrationsParams defines parameters for ListWebhookIntegrations. type ListWebhookIntegrationsParams struct { // StartingAfter Pagination cursor. Pass the id of the last item from the previous page to fetch the next page; omit for the first page. diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go index 55f0b872..c4ff4566 100644 --- a/internal/cli/flows_authoring.go +++ b/internal/cli/flows_authoring.go @@ -33,7 +33,7 @@ func newFlowsShowCmd() *cobra.Command { if err != nil { return err } - draft, err := client.Flows.GetDraft(cmd.Context(), projectID, flowID) + draft, err := client.Flows.Get(cmd.Context(), projectID, flowID, api.FlowExpandSteps) if err != nil { return err } @@ -65,11 +65,11 @@ func newFlowsExportCmd() *cobra.Command { if err != nil { return err } - draft, err := client.Flows.GetDraft(cmd.Context(), projectID, flowID) + draft, err := client.Flows.Get(cmd.Context(), projectID, flowID, api.FlowExpandSteps) if err != nil { return err } - body := api.UpdateFlowDraft{FlowSettings: draft.FlowSettings, Revision: draft.Revision, UpdatedSteps: draft.Steps} + body := api.UpdateFlow{FlowSettings: draft.FlowSettings, Revision: draft.Revision, UpdatedSteps: draft.Steps} data, err := json.MarshalIndent(body, "", " ") if err != nil { return err @@ -97,7 +97,7 @@ func newFlowsUpdateCmd() *cobra.Command { Example: ` rc flows update wf_abc --config flow.json cat flow.json | rc flows update wf_abc --config -`, RunE: func(cmd *cobra.Command, args []string) error { - var body api.UpdateFlowDraft + var body api.UpdateFlow if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { return err } @@ -116,7 +116,7 @@ func newFlowsUpdateCmd() *cobra.Command { if err != nil { return err } - flow, err := client.Flows.UpdateDraft(cmd.Context(), projectID, flowID, body) + flow, err := client.Flows.Update(cmd.Context(), projectID, flowID, body) if err != nil { return err } diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go index 2b84012b..8aba7899 100644 --- a/internal/cli/flows_authoring_test.go +++ b/internal/cli/flows_authoring_test.go @@ -16,12 +16,15 @@ import ( func TestFlowExportUpdateRoundTrip(t *testing.T) { var update map[string]json.RawMessage server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/projects/proj/flows/wf_1/draft" { + if r.URL.Path != "/projects/proj/flows/wf_1" { t.Errorf("path = %s", r.URL.Path) } switch r.Method { case http.MethodGet: - _, _ = io.WriteString(w, `{"id":"wf_1","object":"flow_draft","revision":4,"type":"web-funnel","display_name":"Checkout","initial_step_id":"screen","single_step_fallback_id":null,"app_id":null,"metadata":{"large":9007199254740993},"custom_context_parameters_config":null,"content_max_width":null,"steps":{"screen":{"type":"screen","paywall":{"components_config":{"large":9007199254740993},"components_localizations":{"en_US":{"title":"Hello"}},"haptics_enabled":false}}}}`) + if r.URL.Query().Get("expand") != "steps" { + t.Errorf("expand = %q, want steps", r.URL.Query().Get("expand")) + } + _, _ = io.WriteString(w, `{"id":"wf_1","object":"flow","revision":4,"type":"web-funnel","display_name":"Checkout","initial_step_id":"screen","single_step_fallback_id":null,"app_id":null,"metadata":{"large":9007199254740993},"custom_context_parameters_config":null,"content_max_width":null,"steps":{"screen":{"type":"screen","paywall":{"components_config":{"large":9007199254740993},"components_localizations":{"en_US":{"title":"Hello"}},"haptics_enabled":false}}}}`) case http.MethodPatch: if err := json.NewDecoder(r.Body).Decode(&update); err != nil { t.Error(err) From 7d54e9d050dfd03ffd5f1fd1eee746233db8d869 Mon Sep 17 00:00:00 2001 From: Jamie Holwill Date: Fri, 30 Oct 2026 15:46:13 +0000 Subject: [PATCH 8/8] refactor(flows): move checkpoint create and targeting filters to a separate PR Co-Authored-By: Claude Sonnet 5.5 --- docs/command-surface.md | 3 +- docs/specs/cli-coverage.yaml | 2 - docs/specs/v2-beta-overlay.yaml | 29 ---- internal/api/checkpoints.go | 8 - internal/api/targeting_rules.go | 8 - internal/api/types_gen.go | 8 - internal/cli/checkpoints.go | 46 +---- internal/cli/checkpoints_test.go | 75 -------- internal/cli/targeting.go | 8 +- internal/cli/targeting_checkpoint_test.go | 203 ---------------------- internal/tui/prompt.go | 3 +- 11 files changed, 6 insertions(+), 387 deletions(-) delete mode 100644 internal/cli/targeting_checkpoint_test.go diff --git a/docs/command-surface.md b/docs/command-surface.md index 938f1687..9e66a0c3 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -182,7 +182,6 @@ rc products store screenshot # upload an App Review # Checkpoints (points in the app where Checkpoint rules choose which Flow to show) rc checkpoints list # experimental (hidden from --help until Checkpoint rules roll out) -rc checkpoints create --identifier # register a checkpoint before the SDK encounters it; does not attach or activate a Flow # Paywalls rc paywalls # help; under npx a TTY shows a generate/edit picker (npm launcher sets RC_GUIDED — paywalls-only for now) @@ -220,7 +219,7 @@ rc experiments resume # resumes enrollment; con rc experiments stop # permanent; confirmation/--yes # Targeting rules — development v2 endpoints -rc targeting list [--state ] [--checkpoint-id ] [--flow-id ] +rc targeting list [--state ] rc targeting show # human detail view shows audience, placements, schedule, and checkpoints rc targeting create (--name --offering [--state inactive|active] | --config ) # initially inactive rc targeting update --config # partial update of an Offering or checkpoint rule diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index d587ee08..0f4b84b5 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -229,8 +229,6 @@ endpoints: # Checkpoints (development-status; provided by the beta overlay) - method: GET path: /projects/{project_id}/checkpoints - - method: POST - path: /projects/{project_id}/checkpoints # Audiences (provided by the beta overlay until the published spec includes them) - method: GET diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index b53649c0..af637abe 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -1066,26 +1066,6 @@ paths: responses: '200': description: OK - post: - summary: Create a Checkpoint - operationId: create-checkpoint - x-release-status: development - x-revenuecat-release-gatekeeping: false - x-scopes: - - project_configuration:targeting_rules:read_write - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateCheckpointRequest' - responses: - '201': - description: The Checkpoint was created - '403': - description: Checkpoint access or the required scope is missing - '409': - description: A Checkpoint with this identifier already exists /projects/{project_id}/audiences: parameters: - name: project_id @@ -1373,15 +1353,6 @@ paths: description: OK components: schemas: - CreateCheckpointRequest: - type: object - required: [identifier] - additionalProperties: false - properties: - identifier: - type: string - minLength: 1 - maxLength: 255 Currency: type: string description: ISO 4217 currency code diff --git a/internal/api/checkpoints.go b/internal/api/checkpoints.go index 2b7360b5..d5fcf470 100644 --- a/internal/api/checkpoints.go +++ b/internal/api/checkpoints.go @@ -26,11 +26,3 @@ func (s *CheckpointsService) List(ctx context.Context, projectID string, opts *L } return &out, nil } - -func (s *CheckpointsService) Create(ctx context.Context, projectID string, body CreateCheckpointRequest) (*Checkpoint, error) { - var out Checkpoint - if err := s.c.do(ctx, http.MethodPost, pathCheckpoints(projectID), body, &out); err != nil { - return nil, err - } - return &out, nil -} diff --git a/internal/api/targeting_rules.go b/internal/api/targeting_rules.go index 02236229..e2bb4c3e 100644 --- a/internal/api/targeting_rules.go +++ b/internal/api/targeting_rules.go @@ -29,8 +29,6 @@ type ListTargetingRulesOptions struct { State string Limit int StartingAfter string - CheckpointID string - FlowID string } func (s *TargetingRulesService) List(ctx context.Context, projectID string, opts ListTargetingRulesOptions) (*Page[TargetingRule], error) { @@ -44,12 +42,6 @@ func (s *TargetingRulesService) List(ctx context.Context, projectID string, opts if opts.StartingAfter != "" { q.Set("starting_after", opts.StartingAfter) } - if opts.CheckpointID != "" { - q.Set("checkpoint_id", opts.CheckpointID) - } - if opts.FlowID != "" { - q.Set("flow_id", opts.FlowID) - } path := pathTargetingRules(projectID) if len(q) > 0 { path += "?" + q.Encode() diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 5354aca8..43fefc2e 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -35528,11 +35528,6 @@ type CreateAppStoreConnectSubscriptionInput struct { // CreateAppStoreConnectSubscriptionInputDuration The subscription duration period type CreateAppStoreConnectSubscriptionInputDuration string -// CreateCheckpointRequest defines model for CreateCheckpointRequest. -type CreateCheckpointRequest struct { - Identifier string `json:"identifier"` -} - // CreateWebhookIntegrationInput defines model for CreateWebhookIntegrationInput. type CreateWebhookIntegrationInput struct { // AppID The ID of the app the webhook integration is scoped to @@ -45623,9 +45618,6 @@ type CreateAppJSONRequestBody = AppCreate // UpdateAppJSONRequestBody defines body for UpdateApp for application/json ContentType. type UpdateAppJSONRequestBody UpdateAppJSONBody -// CreateCheckpointJSONRequestBody defines body for CreateCheckpoint for application/json ContentType. -type CreateCheckpointJSONRequestBody = CreateCheckpointRequest - // CreateCustomerJSONRequestBody defines body for CreateCustomer for application/json ContentType. type CreateCustomerJSONRequestBody CreateCustomerJSONBody diff --git a/internal/cli/checkpoints.go b/internal/cli/checkpoints.go index 9a25f809..a0172dd5 100644 --- a/internal/cli/checkpoints.go +++ b/internal/cli/checkpoints.go @@ -1,14 +1,10 @@ package cli import ( - "fmt" - - "github.com/charmbracelet/huh" "github.com/spf13/cobra" "github.com/revenuecat/cli/internal/api" "github.com/revenuecat/cli/internal/output" - "github.com/revenuecat/cli/internal/tui" ) func newCheckpointsCmd() *cobra.Command { @@ -17,50 +13,12 @@ func newCheckpointsCmd() *cobra.Command { Aliases: []string{"checkpoint"}, // Hidden from --help until Checkpoint rules roll out. Annotations: map[string]string{annotationSurface: surfaceExperimental}, - Short: "Manage Checkpoints", + Short: "View Checkpoints", Long: `A Checkpoint is a point in your app, such as app open or after onboarding, where Checkpoint rules decide which Flow a customer sees.`, Example: ` rc checkpoints list`, } - cmd.AddCommand(newCheckpointsListCmd(), newCheckpointsCreateCmd()) - return cmd -} - -func newCheckpointsCreateCmd() *cobra.Command { - var identifier string - cmd := &cobra.Command{ - Use: "create", - Short: "Create a Checkpoint", - Long: `Registers the identifier your app will use before the SDK encounters it. -Requires Checkpoint access for the project. Creating a Checkpoint does not -attach or activate a Flow; use targeting rules to choose what it shows.`, - Example: ` rc checkpoints create --identifier after_onboarding - rc checkpoints create --identifier after_onboarding --json --no-input`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - rt := RuntimeFrom(cmd.Context()) - projectID, err := requireProject(rt) - if err != nil { - return err - } - if err := tui.Form(rt.Globals.NoInput). - Field(huh.NewInput().Title("Identifier").Value(&identifier).Validate(tui.Required("identifier"))). - Run(); err != nil { - return err - } - client, err := rt.API() - if err != nil { - return err - } - checkpoint, err := client.Checkpoints.Create(cmd.Context(), projectID, api.CreateCheckpointRequest{Identifier: identifier}) - if err != nil { - return err - } - rt.Out.Success(fmt.Sprintf("Created checkpoint %s", checkpoint.ID)) - return rt.Out.Render(checkpoint) - }, - } - cmd.Flags().StringVar(&identifier, "identifier", "", "identifier your app uses for the Checkpoint (required)") + cmd.AddCommand(newCheckpointsListCmd()) return cmd } diff --git a/internal/cli/checkpoints_test.go b/internal/cli/checkpoints_test.go index 35462725..c7950740 100644 --- a/internal/cli/checkpoints_test.go +++ b/internal/cli/checkpoints_test.go @@ -1,7 +1,6 @@ package cli_test import ( - "encoding/json" "io" "net/http" "net/http/httptest" @@ -9,80 +8,6 @@ import ( "testing" ) -func TestCheckpointsCreate(t *testing.T) { - for _, jsonOutput := range []bool{false, true} { - name := "human" - if jsonOutput { - name = "json" - } - t.Run(name, func(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - if r.Method != http.MethodPost || r.URL.Path != "/projects/proj/checkpoints" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - } - if body["identifier"] != "after_onboarding" { - t.Errorf("unexpected body: %v", body) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = io.WriteString(w, `{"id":"chkpt1","object":"checkpoint","identifier":"after_onboarding","created_via":"api"}`) - })) - t.Cleanup(server.Close) - args := []string{"checkpoints", "create", "--identifier", "after_onboarding", "--no-input"} - if jsonOutput { - args = append(args, "--json") - } - out, _, err := runProjectSetupCommand(t, server.URL, args...) - if err != nil || requests != 1 { - t.Fatalf("err=%v requests=%d", err, requests) - } - for _, want := range []string{"chkpt1", "after_onboarding"} { - if !strings.Contains(out, want) { - t.Errorf("output missing %q: %s", want, out) - } - } - if jsonOutput && !strings.Contains(out, `"schema_version": 1`) { - t.Errorf("missing JSON envelope: %s", out) - } - }) - } -} - -func TestCheckpointsCreateRequiresIdentifierBeforeRequest(t *testing.T) { - for _, identifier := range []string{"", " "} { - t.Run(identifier, func(t *testing.T) { - requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - })) - t.Cleanup(server.Close) - _, _, err := runProjectSetupCommand(t, server.URL, "checkpoints", "create", "--identifier", identifier, "--no-input") - if err == nil || !strings.Contains(err.Error(), "identifier") || requests != 0 { - t.Fatalf("err=%v requests=%d", err, requests) - } - }) - } -} - -func TestCheckpointsCreateSurfacesAPIError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - _, _ = io.WriteString(w, `{"type":"resource_already_exists","message":"Checkpoint already exists","retryable":false}`) - })) - t.Cleanup(server.Close) - out, _, err := runProjectSetupCommand(t, server.URL, "checkpoints", "create", "--identifier", "after_onboarding", "--json", "--no-input") - if err == nil || !strings.Contains(err.Error(), "Checkpoint already exists") || strings.Contains(out, `"data"`) { - t.Fatalf("out=%s err=%v", out, err) - } -} - func TestCheckpointsList_ShowsIdentifiersAndPaginates(t *testing.T) { var gotLimit, gotCursor string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cli/targeting.go b/internal/cli/targeting.go index 019f0af3..10c071dc 100644 --- a/internal/cli/targeting.go +++ b/internal/cli/targeting.go @@ -24,7 +24,7 @@ func newTargetingCmd() *cobra.Command { } func newTargetingListCmd() *cobra.Command { - var state, cursor, checkpointID, flowID string + var state, cursor string var limit int cmd := &cobra.Command{ Use: "list", @@ -41,9 +41,7 @@ func newTargetingListCmd() *cobra.Command { if err != nil { return err } - page, err := client.TargetingRules.List(cmd.Context(), projectID, api.ListTargetingRulesOptions{ - State: state, Limit: limit, StartingAfter: cursor, CheckpointID: checkpointID, FlowID: flowID, - }) + page, err := client.TargetingRules.List(cmd.Context(), projectID, api.ListTargetingRulesOptions{State: state, Limit: limit, StartingAfter: cursor}) if err != nil { return err } @@ -68,8 +66,6 @@ func newTargetingListCmd() *cobra.Command { cmd.Flags().StringVar(&state, "state", "", "filter by active, scheduled (checkpoint only), or inactive; future-scheduled Offering rules are active") cmd.Flags().IntVar(&limit, "limit", 20, "maximum rules to return (1–100)") cmd.Flags().StringVar(&cursor, "cursor", "", "item ID to start after (pagination)") - cmd.Flags().StringVar(&checkpointID, "checkpoint-id", "", "filter rules by Checkpoint ID") - cmd.Flags().StringVar(&flowID, "flow-id", "", "filter rules by Flow ID") return cmd } diff --git a/internal/cli/targeting_checkpoint_test.go b/internal/cli/targeting_checkpoint_test.go deleted file mode 100644 index 25ef2df5..00000000 --- a/internal/cli/targeting_checkpoint_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCheckpointTargetingListFilters(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet || r.URL.Path != "/projects/proj/targeting_rules" { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - } - for key, want := range map[string]string{ - "checkpoint_id": "chkpt1", "flow_id": "wf1", "state": "inactive", "limit": "3", "starting_after": "chkptrule0", - } { - if got := r.URL.Query().Get(key); got != want { - t.Errorf("%s=%q, want %q", key, got, want) - } - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"object":"list","items":[{"id":"chkptrule1","rule_type":"checkpoint","flow_id":"wf1","state":"inactive","display_name":"Onboarding"}],"next_page":null}`) - })) - t.Cleanup(server.Close) - out, _, err := runProjectSetupCommand(t, server.URL, "targeting", "list", "--checkpoint-id", "chkpt1", "--flow-id", "wf1", "--state", "inactive", "--limit", "3", "--cursor", "chkptrule0", "--json", "--no-input") - if err != nil || !strings.Contains(out, "chkptrule1") { - t.Fatalf("out=%s err=%v", out, err) - } -} - -func TestCheckpointTargetingCreatePreservesPositionAfterApproval(t *testing.T) { - for _, position := range []int{0, 3} { - t.Run(fmt.Sprint(position), func(t *testing.T) { - var posted struct { - Checkpoints []struct { - ID string `json:"checkpoint_id"` - Position *int `json:"position"` - } `json:"checkpoints"` - } - mutations := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mutations++ - if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { - t.Error(err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"chkptrule1","rule_type":"checkpoint","state":"active","display_name":"Onboarding"}`) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - config := fmt.Sprintf(`{"rule_type":"checkpoint","state":"active","display_name":"Onboarding","audience_id":"aud1","flow_id":"wf1","checkpoints":[{"checkpoint_id":"chkpt1","position":%d}]}`, position) - path := filepath.Join(t.TempDir(), "create.json") - if err := os.WriteFile(path, []byte(config), 0o600); err != nil { - t.Fatal(err) - } - args := []string{"targeting", "create", "--config", path, "--project-id", "proj", "--api-key", "sk_test", "--no-input"} - _, preview, err := runAgentCmd(t, args...) - if err == nil || !strings.Contains(err.Error(), "--yes") || mutations != 0 || !strings.Contains(preview, fmt.Sprintf("chkpt1 (position %d)", position)) { - t.Fatalf("approval preview: err=%v mutations=%d preview=%s", err, mutations, preview) - } - _, _, err = runAgentCmd(t, append(args, "--yes", "--json")...) - if err != nil || mutations != 1 || len(posted.Checkpoints) != 1 { - t.Fatalf("create: err=%v mutations=%d body=%+v", err, mutations, posted) - } - checkpoint := posted.Checkpoints[0] - if checkpoint.ID != "chkpt1" || checkpoint.Position == nil || *checkpoint.Position != position { - t.Fatalf("checkpoint position changed: %+v", checkpoint) - } - }) - } -} - -func TestCheckpointTargetingCreateRejectsInvalidCheckpointsBeforeMutation(t *testing.T) { - for _, checkpoints := range []string{`[]`, `null`, `{}`, `[{"checkpoint_id":""}]`, `[{}]`, `[{"checkpoint_id":"a"},{"checkpoint_id":"b"}]`} { - t.Run(checkpoints, func(t *testing.T) { - requests := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"chkptrule1","rule_type":"checkpoint"}`) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - config := `{"rule_type":"checkpoint","state":"active","display_name":"Onboarding","audience_id":"aud1","flow_id":"wf1","checkpoints":` + checkpoints + `}` - path := filepath.Join(t.TempDir(), "create.json") - if err := os.WriteFile(path, []byte(config), 0o600); err != nil { - t.Fatal(err) - } - _, _, err := runAgentCmd(t, "targeting", "create", "--config", path, "--project-id", "proj", "--api-key", "sk_test", "--no-input", "--yes") - if err == nil || !strings.Contains(err.Error(), "checkpoint") || requests != 0 { - t.Fatalf("err=%v requests=%d; expected validation before mutation", err, requests) - } - }) - } -} - -func TestCheckpointTargetingUpdateGuardsLiveAndScheduledChanges(t *testing.T) { - for _, tc := range []struct { - name, currentState, newState string - approval bool - }{ - {"activate", "inactive", "active", true}, - {"schedule", "inactive", "scheduled", true}, - {"edit active", "active", "", true}, - {"edit scheduled", "scheduled", "", true}, - {"edit inactive", "inactive", "", false}, - } { - t.Run(tc.name, func(t *testing.T) { - var posted map[string]any - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/projects/proj/targeting_rules/chkptrule1" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Method == http.MethodPost { - if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { - t.Error(err) - } - } - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"id":"chkptrule1","rule_type":"checkpoint","state":%q,"display_name":"Onboarding","flow_id":"wf_original","audience_id":"aud_original"}`, tc.currentState) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - config := map[string]any{ - "display_name": "New onboarding", "flow_id": "wf_new", "audience_id": "aud_new", - "checkpoints": []map[string]string{{"checkpoint_id": "chkpt_new"}}, - "schedule": map[string]string{"start_date": "2030-12-01T00:00:00Z", "end_date": "2030-12-31T23:59:59Z"}, - } - if tc.newState != "" { - config["state"] = tc.newState - } - data, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - configPath := filepath.Join(t.TempDir(), "checkpoint.json") - if err := os.WriteFile(configPath, data, 0o600); err != nil { - t.Fatal(err) - } - args := []string{"targeting", "update", "chkptrule1", "--config", configPath, "--project-id", "proj", "--api-key", "sk_test", "--no-input"} - if tc.approval { - _, preview, err := runAgentCmd(t, args...) - if err == nil || !strings.Contains(err.Error(), "--yes") || posted != nil { - t.Fatalf("approval did not precede mutation: err=%v posted=%v", err, posted) - } - for _, want := range []string{"Current flow", "wf_original", "wf_new", "chkpt_new", "2030-12-01T00:00:00Z"} { - if !strings.Contains(preview, want) { - t.Fatalf("missing %q in preview: %s", want, preview) - } - } - args = append(args, "--yes") - } - out, stderr, err := runAgentCmd(t, append(args, "--json")...) - if err != nil || stderr != "" || posted == nil || !strings.Contains(out, `"rule_type": "checkpoint"`) { - t.Fatalf("checkpoint update failed: err=%v posted=%v stdout=%s stderr=%s", err, posted, out, stderr) - } - postedJSON, err := json.Marshal(posted) - if err != nil || string(postedJSON) != string(data) { - t.Fatalf("request changed: got=%s want=%s err=%v", postedJSON, data, err) - } - }) - } -} - -func TestTargetingUpdateRejectsFieldsForWrongRuleType(t *testing.T) { - for _, tc := range []struct{ name, ruleType, config, want string }{ - {"checkpoint position", "checkpoint", `{"position":1}`, "cannot be set on checkpoint"}, - {"checkpoint offering", "checkpoint", `{"offering_id":"ofrng1"}`, "cannot be set on checkpoint"}, - {"checkpoint conditions", "checkpoint", `{"conditions":[]}`, "cannot be set on checkpoint"}, - {"checkpoint placements", "checkpoint", `{"placements":null}`, "cannot be set on checkpoint"}, - {"checkpoint null audience", "checkpoint", `{"audience_id":null}`, "nonempty string"}, - {"Offering flow", "legacy", `{"flow_id":"wf1"}`, "only be set on checkpoint"}, - {"Offering checkpoints", "legacy", `{"checkpoints":[{"checkpoint_id":"chkpt1"}]}`, "only be set on checkpoint"}, - {"Offering scheduled state", "legacy", `{"state":"scheduled"}`, "state must be active or inactive"}, - } { - t.Run(tc.name, func(t *testing.T) { - mutations := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - mutations++ - } - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"id":"rule1","rule_type":%q,"state":"inactive"}`, tc.ruleType) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - configPath := filepath.Join(t.TempDir(), "update.json") - if err := os.WriteFile(configPath, []byte(tc.config), 0o600); err != nil { - t.Fatal(err) - } - _, _, err := runAgentCmd(t, "targeting", "update", "rule1", "--config", configPath, "--project-id", "proj", "--api-key", "sk_test", "--no-input", "--yes") - if err == nil || !strings.Contains(err.Error(), tc.want) || mutations != 0 { - t.Fatalf("err=%v mutations=%d; want %q before mutation", err, mutations, tc.want) - } - }) - } -} diff --git a/internal/tui/prompt.go b/internal/tui/prompt.go index 07b01be8..57730054 100644 --- a/internal/tui/prompt.go +++ b/internal/tui/prompt.go @@ -13,7 +13,6 @@ import ( "fmt" "io" "os" - "strings" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" @@ -136,7 +135,7 @@ func ConfirmDefault(noInput bool, msg string, defaultValue bool) (bool, error) { func Required(label string) func(string) error { return func(s string) error { - if strings.TrimSpace(s) == "" { + if s == "" { return errors.New(label + " is required") } return nil