From 57d697d3a7579534cce8c8d14245a2228e3b7271 Mon Sep 17 00:00:00 2001 From: Murillo Date: Tue, 12 May 2026 16:11:03 -0300 Subject: [PATCH 1/6] fix(coupons): add NOT NULL constraint to code column --- ...0260512155310_add_not_null_to_coupon_code.rb | 17 +++++++++++++++++ db/structure.sql | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260512155310_add_not_null_to_coupon_code.rb diff --git a/db/migrate/20260512155310_add_not_null_to_coupon_code.rb b/db/migrate/20260512155310_add_not_null_to_coupon_code.rb new file mode 100644 index 00000000000..4eb767b106d --- /dev/null +++ b/db/migrate/20260512155310_add_not_null_to_coupon_code.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddNotNullToCouponCode < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL + UPDATE coupons SET code = 'coupon-' || LEFT(id::text, 8) WHERE code IS NULL; + SQL + + change_column_null :coupons, :code, false + end + end + + def down + change_column_null :coupons, :code, true + end +end diff --git a/db/structure.sql b/db/structure.sql index 7fe23153aad..16a9cf1f401 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2110,7 +2110,7 @@ CREATE TABLE public.coupons ( id uuid DEFAULT gen_random_uuid() NOT NULL, organization_id uuid NOT NULL, name character varying NOT NULL, - code character varying, + code character varying NOT NULL, status integer DEFAULT 0 NOT NULL, terminated_at timestamp(6) without time zone, amount_cents bigint, @@ -12215,6 +12215,7 @@ ALTER TABLE ONLY public.membership_roles SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260512155310'), ('20260504134804'), ('20260430102814'), ('20260430102813'), From b9940b94820ab83ed257fb81d897d1717b6fab15 Mon Sep 17 00:00:00 2001 From: Murillo Date: Tue, 12 May 2026 16:11:06 -0300 Subject: [PATCH 2/6] fix(coupons): enforce code as required field --- app/graphql/types/coupons/create_input.rb | 2 +- app/graphql/types/coupons/object.rb | 2 +- app/models/coupon.rb | 2 +- schema.graphql | 6 ++--- schema.json | 32 ++++++++++++++++------- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/app/graphql/types/coupons/create_input.rb b/app/graphql/types/coupons/create_input.rb index 2aebb09a038..e9a096d5db7 100644 --- a/app/graphql/types/coupons/create_input.rb +++ b/app/graphql/types/coupons/create_input.rb @@ -7,7 +7,7 @@ class CreateInput < Types::BaseInputObject argument :amount_cents, GraphQL::Types::BigInt, required: false argument :amount_currency, Types::CurrencyEnum, required: false - argument :code, String, required: false + argument :code, String, required: true argument :coupon_type, Types::Coupons::CouponTypeEnum, required: true argument :description, String, required: false argument :frequency, Types::Coupons::FrequencyEnum, required: true diff --git a/app/graphql/types/coupons/object.rb b/app/graphql/types/coupons/object.rb index 43e5fe5c9ac..d3339ea3195 100644 --- a/app/graphql/types/coupons/object.rb +++ b/app/graphql/types/coupons/object.rb @@ -10,7 +10,7 @@ class Object < Types::BaseObject field :amount_cents, GraphQL::Types::BigInt, null: true field :amount_currency, Types::CurrencyEnum, null: true - field :code, String, null: true + field :code, String, null: false field :coupon_type, Types::Coupons::CouponTypeEnum, null: false field :description, String, null: true field :frequency, Types::Coupons::FrequencyEnum, null: false diff --git a/app/models/coupon.rb b/app/models/coupon.rb index 30879336ea2..a8d99808892 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -49,7 +49,7 @@ class Coupon < ApplicationRecord monetize :amount_cents, disable_validation: true, allow_nil: true validates :name, presence: true - validates :code, uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} + validates :code, presence: true, uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} validates :amount_cents, presence: true, if: :fixed_amount? validates :amount_cents, numericality: {greater_than: 0}, allow_nil: true diff --git a/schema.graphql b/schema.graphql index 7882f79de02..42995a21eaa 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2547,7 +2547,7 @@ type Coupon { amountCurrency: CurrencyEnum appliedCouponsCount: Int! billableMetrics: [BillableMetric!] - code: String + code: String! couponType: CouponTypeEnum! createdAt: ISO8601DateTime! @@ -2790,7 +2790,7 @@ input CreateCouponInput { A unique identifier for the client performing the mutation. """ clientMutationId: String - code: String + code: String! couponType: CouponTypeEnum! description: String expiration: CouponExpiration! @@ -12582,7 +12582,7 @@ input UpdateCouponInput { A unique identifier for the client performing the mutation. """ clientMutationId: String - code: String + code: String! couponType: CouponTypeEnum! description: String expiration: CouponExpiration! diff --git a/schema.json b/schema.json index 21d93014a50..edaf4da1f75 100644 --- a/schema.json +++ b/schema.json @@ -9761,9 +9761,13 @@ "description": null, "args": [], "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } }, "isDeprecated": false, "deprecationReason": null @@ -11432,9 +11436,13 @@ "name": "code", "description": null, "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } }, "defaultValue": null, "isDeprecated": false, @@ -65498,9 +65506,13 @@ "name": "code", "description": null, "type": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } }, "defaultValue": null, "isDeprecated": false, @@ -74877,4 +74889,4 @@ ] } } -} \ No newline at end of file +} From 38ea5804874f147f0e07ed8514319f21814099db Mon Sep 17 00:00:00 2001 From: Murillo Date: Tue, 12 May 2026 16:11:10 -0300 Subject: [PATCH 3/6] test(coupons): assert code presence and non-null type --- spec/graphql/types/coupons/object_spec.rb | 2 +- spec/models/coupon_spec.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/graphql/types/coupons/object_spec.rb b/spec/graphql/types/coupons/object_spec.rb index 1cd982547a1..f476e5ef721 100644 --- a/spec/graphql/types/coupons/object_spec.rb +++ b/spec/graphql/types/coupons/object_spec.rb @@ -10,7 +10,7 @@ expect(subject).to have_field(:organization).of_type("Organization") expect(subject).to have_field(:amount_cents).of_type("BigInt") expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum") - expect(subject).to have_field(:code).of_type("String") + expect(subject).to have_field(:code).of_type("String!") expect(subject).to have_field(:coupon_type).of_type("CouponTypeEnum!") expect(subject).to have_field(:description).of_type("String") expect(subject).to have_field(:frequency).of_type("CouponFrequency!") diff --git a/spec/models/coupon_spec.rb b/spec/models/coupon_spec.rb index 6fb56a2bff8..2ef94e7903e 100644 --- a/spec/models/coupon_spec.rb +++ b/spec/models/coupon_spec.rb @@ -21,6 +21,7 @@ describe "validations" do it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:code) } it { is_expected.to validate_exclusion_of(:reusable).in_array([nil]) } describe "of amount cents" do From af4d192af78dcf3c6f59947a4a92405d293720f0 Mon Sep 17 00:00:00 2001 From: Murillo Date: Wed, 13 May 2026 11:02:06 -0300 Subject: [PATCH 4/6] changes: review comments --- app/graphql/types/coupons/update_input.rb | 1 + ...0260512155310_add_not_null_to_coupon_code.rb | 17 ----------------- .../20260512155310_backfill_coupon_codes.rb | 16 ++++++++++++++++ ...code_not_null_check_constraint_to_coupons.rb | 8 ++++++++ ...0513105210_validate_coupons_code_not_null.rb | 11 +++++++++++ db/structure.sql | 2 ++ 6 files changed, 38 insertions(+), 17 deletions(-) delete mode 100644 db/migrate/20260512155310_add_not_null_to_coupon_code.rb create mode 100644 db/migrate/20260512155310_backfill_coupon_codes.rb create mode 100644 db/migrate/20260513105209_add_code_not_null_check_constraint_to_coupons.rb create mode 100644 db/migrate/20260513105210_validate_coupons_code_not_null.rb diff --git a/app/graphql/types/coupons/update_input.rb b/app/graphql/types/coupons/update_input.rb index 13a156c106c..2d650a2b593 100644 --- a/app/graphql/types/coupons/update_input.rb +++ b/app/graphql/types/coupons/update_input.rb @@ -6,6 +6,7 @@ class UpdateInput < Types::Coupons::CreateInput graphql_name "UpdateCouponInput" argument :id, String, required: true + argument :code, String, required: false end end end diff --git a/db/migrate/20260512155310_add_not_null_to_coupon_code.rb b/db/migrate/20260512155310_add_not_null_to_coupon_code.rb deleted file mode 100644 index 4eb767b106d..00000000000 --- a/db/migrate/20260512155310_add_not_null_to_coupon_code.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class AddNotNullToCouponCode < ActiveRecord::Migration[8.0] - def up - safety_assured do - execute <<~SQL - UPDATE coupons SET code = 'coupon-' || LEFT(id::text, 8) WHERE code IS NULL; - SQL - - change_column_null :coupons, :code, false - end - end - - def down - change_column_null :coupons, :code, true - end -end diff --git a/db/migrate/20260512155310_backfill_coupon_codes.rb b/db/migrate/20260512155310_backfill_coupon_codes.rb new file mode 100644 index 00000000000..1593d5abf19 --- /dev/null +++ b/db/migrate/20260512155310_backfill_coupon_codes.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class BackfillCouponCodes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + Coupon.unscoped.where(code: nil).find_in_batches(batch_size: 1000) do |batch| + Coupon.unscoped.where(id: batch.pluck(:id)) + .update_all("code = 'coupon-' || id::text") # rubocop:disable Rails/SkipsModelValidations + end + end + + def down + # irreversible + end +end diff --git a/db/migrate/20260513105209_add_code_not_null_check_constraint_to_coupons.rb b/db/migrate/20260513105209_add_code_not_null_check_constraint_to_coupons.rb new file mode 100644 index 00000000000..b7f4516ed89 --- /dev/null +++ b/db/migrate/20260513105209_add_code_not_null_check_constraint_to_coupons.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddCodeNotNullCheckConstraintToCoupons < ActiveRecord::Migration[8.0] + def change + add_check_constraint :coupons, "code IS NOT NULL", + name: "coupons_code_not_null", validate: false, if_not_exists: true + end +end diff --git a/db/migrate/20260513105210_validate_coupons_code_not_null.rb b/db/migrate/20260513105210_validate_coupons_code_not_null.rb new file mode 100644 index 00000000000..1ff2c19e277 --- /dev/null +++ b/db/migrate/20260513105210_validate_coupons_code_not_null.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ValidateCouponsCodeNotNull < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + validate_check_constraint :coupons, name: "coupons_code_not_null" + change_column_null :coupons, :code, false + remove_check_constraint :coupons, name: "coupons_code_not_null" + end +end diff --git a/db/structure.sql b/db/structure.sql index 16a9cf1f401..3a033e2516d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -12215,6 +12215,8 @@ ALTER TABLE ONLY public.membership_roles SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES +('20260513105210'), +('20260513105209'), ('20260512155310'), ('20260504134804'), ('20260430102814'), From e1c8450c7d4940627d05ea79aaee329e12c2438c Mon Sep 17 00:00:00 2001 From: Murillo Date: Wed, 13 May 2026 11:06:32 -0300 Subject: [PATCH 5/6] changes: make migration reversible --- app/models/coupon.rb | 2 +- .../20260513105210_validate_coupons_code_not_null.rb | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/models/coupon.rb b/app/models/coupon.rb index a8d99808892..dd3b5b7027f 100644 --- a/app/models/coupon.rb +++ b/app/models/coupon.rb @@ -101,7 +101,7 @@ def parent_and_overriden_plans # id :uuid not null, primary key # amount_cents :bigint # amount_currency :string -# code :string +# code :string not null # coupon_type :integer default("fixed_amount"), not null # deleted_at :datetime # description :text diff --git a/db/migrate/20260513105210_validate_coupons_code_not_null.rb b/db/migrate/20260513105210_validate_coupons_code_not_null.rb index 1ff2c19e277..dc4fc733d57 100644 --- a/db/migrate/20260513105210_validate_coupons_code_not_null.rb +++ b/db/migrate/20260513105210_validate_coupons_code_not_null.rb @@ -3,9 +3,14 @@ class ValidateCouponsCodeNotNull < ActiveRecord::Migration[8.0] disable_ddl_transaction! - def change + def up validate_check_constraint :coupons, name: "coupons_code_not_null" change_column_null :coupons, :code, false remove_check_constraint :coupons, name: "coupons_code_not_null" end + + def down + add_check_constraint :coupons, "code IS NOT NULL", name: "coupons_code_not_null", validate: false + change_column_null :coupons, :code, true + end end From dee42b4db6b1ecc03643ba19984bb17d46378dd9 Mon Sep 17 00:00:00 2001 From: Murillo Date: Wed, 13 May 2026 11:24:16 -0300 Subject: [PATCH 6/6] fix: linter and graphql schema --- app/graphql/types/coupons/update_input.rb | 2 +- schema.graphql | 2 +- schema.json | 10 +++------- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/app/graphql/types/coupons/update_input.rb b/app/graphql/types/coupons/update_input.rb index 2d650a2b593..305987c4f25 100644 --- a/app/graphql/types/coupons/update_input.rb +++ b/app/graphql/types/coupons/update_input.rb @@ -5,8 +5,8 @@ module Coupons class UpdateInput < Types::Coupons::CreateInput graphql_name "UpdateCouponInput" - argument :id, String, required: true argument :code, String, required: false + argument :id, String, required: true end end end diff --git a/schema.graphql b/schema.graphql index 42995a21eaa..2376934c82f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -12582,7 +12582,7 @@ input UpdateCouponInput { A unique identifier for the client performing the mutation. """ clientMutationId: String - code: String! + code: String couponType: CouponTypeEnum! description: String expiration: CouponExpiration! diff --git a/schema.json b/schema.json index edaf4da1f75..75ac5955296 100644 --- a/schema.json +++ b/schema.json @@ -65506,13 +65506,9 @@ "name": "code", "description": null, "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } + "kind": "SCALAR", + "name": "String", + "ofType": null }, "defaultValue": null, "isDeprecated": false,