From d124418111ed79260cdf2f798525980c52e1a6ad Mon Sep 17 00:00:00 2001 From: Anas Shahid Date: Tue, 15 Jul 2025 21:38:04 -0400 Subject: [PATCH 1/3] Introduce User onboarding progress table Fixes: https://gitlab.com/gitlab-org/gitlab/-/issues/547921 EE: true Implements a new normalized data model for tracking user onboarding progress in Learn GitLab. This replaces the previous column-per-task design with a more scalable row-per-task approach. The new architecture eliminates the need for schema changes when adding or modifying onboarding tasks. Tasks are now defined as enum values, allowing new tasks to be added through code changes rather than database migrations. This change supports the future goal of providing user-centric onboarding experiences across both SaaS and self-managed GitLab instances, while maintaining compatibility with the existing namespace-based approach. Key improvements: - Reduces implementation friction for adding new onboarding tasks - Establishes a single source of truth for task definitions - Improves scalability for future onboarding enhancements - Simplifies the codebase by removing distributed task logic --- db/docs/user_onboarding_progresses.yml | 12 +++ ...create_user_onboarding_progresses_table.rb | 15 ++++ db/schema_migrations/20250711150714 | 1 + db/structure.sql | 24 ++++++ .../onboarding/user_onboarding_progress.rb | 35 ++++++++ .../onboarding/user_onboarding_progresses.rb | 8 ++ .../user_onboarding_progress_spec.rb | 86 +++++++++++++++++++ 7 files changed, 181 insertions(+) create mode 100644 db/docs/user_onboarding_progresses.yml create mode 100644 db/migrate/20250711150714_create_user_onboarding_progresses_table.rb create mode 100644 db/schema_migrations/20250711150714 create mode 100644 ee/app/models/onboarding/user_onboarding_progress.rb create mode 100644 ee/spec/factories/onboarding/user_onboarding_progresses.rb create mode 100644 ee/spec/models/onboarding/user_onboarding_progress_spec.rb diff --git a/db/docs/user_onboarding_progresses.yml b/db/docs/user_onboarding_progresses.yml new file mode 100644 index 00000000000000..7df6ae876abe33 --- /dev/null +++ b/db/docs/user_onboarding_progresses.yml @@ -0,0 +1,12 @@ +--- +table_name: user_onboarding_progresses +classes: +- Onboarding::UserOnboardingProgress +feature_categories: +- onboarding +description: Tracks user progress through onboarding tasks +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/197667 +milestone: '18.3' +gitlab_schema: gitlab_main_user +sharding_key: + user_id: users diff --git a/db/migrate/20250711150714_create_user_onboarding_progresses_table.rb b/db/migrate/20250711150714_create_user_onboarding_progresses_table.rb new file mode 100644 index 00000000000000..f45c7263d9824f --- /dev/null +++ b/db/migrate/20250711150714_create_user_onboarding_progresses_table.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateUserOnboardingProgressesTable < Gitlab::Database::Migration[2.3] + milestone '18.3' + + def change + create_table :user_onboarding_progresses do |t| + t.timestamps_with_timezone null: false + t.bigint :user_id, null: false + t.integer :task, null: false, limit: 2 + + t.index [:user_id, :task], unique: true, name: 'index_user_onboarding_progresses_on_user_id_and_task' + end + end +end diff --git a/db/schema_migrations/20250711150714 b/db/schema_migrations/20250711150714 new file mode 100644 index 00000000000000..21b46dfdefd8f9 --- /dev/null +++ b/db/schema_migrations/20250711150714 @@ -0,0 +1 @@ +76fa79b27caa320a482a35dcfd2fc5789b78cac13a90d748b71985936d2844cc \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index a5489c76e50f51..e99a1eafec33bc 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -25045,6 +25045,23 @@ CREATE SEQUENCE user_namespace_callouts_id_seq ALTER SEQUENCE user_namespace_callouts_id_seq OWNED BY user_namespace_callouts.id; +CREATE TABLE user_onboarding_progresses ( + id bigint NOT NULL, + user_id bigint NOT NULL, + task smallint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE user_onboarding_progresses_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE user_onboarding_progresses_id_seq OWNED BY user_onboarding_progresses.id; + CREATE TABLE user_permission_export_upload_uploads ( id bigint NOT NULL, size bigint NOT NULL, @@ -28867,6 +28884,8 @@ ALTER TABLE ONLY user_member_roles ALTER COLUMN id SET DEFAULT nextval('user_mem ALTER TABLE ONLY user_namespace_callouts ALTER COLUMN id SET DEFAULT nextval('user_namespace_callouts_id_seq'::regclass); +ALTER TABLE ONLY user_onboarding_progresses ALTER COLUMN id SET DEFAULT nextval('user_onboarding_progresses_id_seq'::regclass); + ALTER TABLE ONLY user_permission_export_uploads ALTER COLUMN id SET DEFAULT nextval('user_permission_export_uploads_id_seq'::regclass); ALTER TABLE ONLY user_preferences ALTER COLUMN id SET DEFAULT nextval('user_preferences_id_seq'::regclass); @@ -32059,6 +32078,9 @@ ALTER TABLE ONLY user_member_roles ALTER TABLE ONLY user_namespace_callouts ADD CONSTRAINT user_namespace_callouts_pkey PRIMARY KEY (id); +ALTER TABLE ONLY user_onboarding_progresses + ADD CONSTRAINT user_onboarding_progresses_pkey PRIMARY KEY (id); + ALTER TABLE ONLY user_permission_export_upload_uploads ADD CONSTRAINT user_permission_export_upload_uploads_pkey PRIMARY KEY (id, model_type); @@ -38612,6 +38634,8 @@ CREATE INDEX index_user_id_and_notification_email_to_notification_settings ON no CREATE INDEX index_user_namespace_callouts_on_namespace_id ON user_namespace_callouts USING btree (namespace_id); +CREATE UNIQUE INDEX index_user_onboarding_progresses_on_user_id_and_task ON user_onboarding_progresses USING btree (user_id, task); + CREATE INDEX index_user_permission_export_uploads_on_user_id_and_status ON user_permission_export_uploads USING btree (user_id, status); CREATE INDEX index_user_phone_number_validations_on_telesign_reference_xid ON user_phone_number_validations USING btree (telesign_reference_xid); diff --git a/ee/app/models/onboarding/user_onboarding_progress.rb b/ee/app/models/onboarding/user_onboarding_progress.rb new file mode 100644 index 00000000000000..f142c1fc046875 --- /dev/null +++ b/ee/app/models/onboarding/user_onboarding_progress.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Onboarding + class UserOnboardingProgress < ApplicationRecord + self.table_name = 'user_onboarding_progresses' + + # Define enum for tasks - mapping to the existing Learn GitLab tasks + # Using integer values explicitly to maintain stability when tasks are added/removed + enum :task, { + merge_request_created: 0, + pipeline_created: 1, + user_added: 2, + trial_started: 3, + required_mr_approvals_enabled: 4, + code_owners_enabled: 5, + issue_created: 6, + secure_dependency_scanning_run: 7, + secure_dast_run: 8, + license_scanning_run: 9, + code_added: 10, + duo_seat_assigned: 11 + }, prefix: true + + belongs_to :user, optional: false + + validates :user, presence: true + validates :task, presence: true, uniqueness: { scope: :user_id } + + scope :for_user, ->(user) { where(user: user) } + scope :by_completion, -> { order(created_at: :asc) } + scope :with_task, ->(task_name) { where(task: task_name) } + + alias_attribute :completed_at, :created_at + end +end diff --git a/ee/spec/factories/onboarding/user_onboarding_progresses.rb b/ee/spec/factories/onboarding/user_onboarding_progresses.rb new file mode 100644 index 00000000000000..62f122b577191a --- /dev/null +++ b/ee/spec/factories/onboarding/user_onboarding_progresses.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user_onboarding_progress, class: 'Onboarding::UserOnboardingProgress' do + user + task { :merge_request_created } + end +end diff --git a/ee/spec/models/onboarding/user_onboarding_progress_spec.rb b/ee/spec/models/onboarding/user_onboarding_progress_spec.rb new file mode 100644 index 00000000000000..47e11a50f89186 --- /dev/null +++ b/ee/spec/models/onboarding/user_onboarding_progress_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Onboarding::UserOnboardingProgress, feature_category: :onboarding do + describe 'associations' do + it { is_expected.to belong_to(:user).required } + end + + describe 'validations' do + subject { build(:user_onboarding_progress) } + + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:task) } + it { is_expected.to validate_uniqueness_of(:task).scoped_to(:user_id).ignoring_case_sensitivity } + end + + describe 'enums' do + it 'defines task enum with prefix and correct values' do + is_expected.to define_enum_for(:task) + .with_prefix + .with_values( + merge_request_created: 0, + pipeline_created: 1, + user_added: 2, + trial_started: 3, + required_mr_approvals_enabled: 4, + code_owners_enabled: 5, + issue_created: 6, + secure_dependency_scanning_run: 7, + secure_dast_run: 8, + license_scanning_run: 9, + code_added: 10, + duo_seat_assigned: 11 + ) + end + end + + describe 'scopes' do + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + let_it_be(:user_mr_created) do + create(:user_onboarding_progress, user: user, task: :merge_request_created, created_at: 2.days.ago) + end + + let_it_be(:user_pipeline_created) do + create(:user_onboarding_progress, user: user, task: :pipeline_created, created_at: 1.day.ago) + end + + let_it_be(:other_user_mr_created) do + create(:user_onboarding_progress, user: other_user, task: :merge_request_created) + end + + describe '.for_user' do + it 'returns progress records for the specified user' do + results = described_class.for_user(user) + expect(results).to contain_exactly(user_mr_created, user_pipeline_created) + end + end + + describe '.by_completion' do + it 'orders records by created_at ascending' do + results = described_class.for_user(user).by_completion + expect(results).to contain_exactly(user_mr_created, user_pipeline_created) + end + end + + describe '.with_task' do + it 'returns records with the specified task' do + results = described_class.with_task(:merge_request_created) + expect(results).to contain_exactly(user_mr_created, other_user_mr_created) + end + end + end + + describe '#completed_at' do + it 'is an alias for created_at' do + expect(described_class.attribute_aliases['completed_at']).to eq('created_at') + end + + it 'returns the created_at timestamp' do + create(:user_onboarding_progress) + expect(described_class.instance_method(:completed_at).original_name).to eq(:created_at) + end + end +end -- GitLab From 4ebdf6dc927dc659e22a0e0fbb23f5a9e1554620 Mon Sep 17 00:00:00 2001 From: Anas Shahid Date: Thu, 17 Jul 2025 23:41:58 -0400 Subject: [PATCH 2/3] Rescope the onboarding model --- .../onboarding/user_onboarding_progress.rb | 35 ++++---- .../user_onboarding_progress_spec.rb | 81 ++++--------------- 2 files changed, 30 insertions(+), 86 deletions(-) diff --git a/ee/app/models/onboarding/user_onboarding_progress.rb b/ee/app/models/onboarding/user_onboarding_progress.rb index f142c1fc046875..dd56100d4d671e 100644 --- a/ee/app/models/onboarding/user_onboarding_progress.rb +++ b/ee/app/models/onboarding/user_onboarding_progress.rb @@ -7,29 +7,22 @@ class UserOnboardingProgress < ApplicationRecord # Define enum for tasks - mapping to the existing Learn GitLab tasks # Using integer values explicitly to maintain stability when tasks are added/removed enum :task, { - merge_request_created: 0, - pipeline_created: 1, - user_added: 2, - trial_started: 3, - required_mr_approvals_enabled: 4, - code_owners_enabled: 5, - issue_created: 6, - secure_dependency_scanning_run: 7, - secure_dast_run: 8, - license_scanning_run: 9, - code_added: 10, - duo_seat_assigned: 11 - }, prefix: true + repository_created: 0, + merge_request_created: 1, + pipeline_created: 2, + user_added: 3, + trial_started: 4, + required_mr_approvals_enabled: 5, + code_owners_enabled: 6, + issue_created: 7, + secure_dependency_scanning_run: 8, + secure_dast_run: 9, + license_scanning_run: 10, + code_added: 11, + duo_seat_assigned: 12 + } belongs_to :user, optional: false - - validates :user, presence: true validates :task, presence: true, uniqueness: { scope: :user_id } - - scope :for_user, ->(user) { where(user: user) } - scope :by_completion, -> { order(created_at: :asc) } - scope :with_task, ->(task_name) { where(task: task_name) } - - alias_attribute :completed_at, :created_at end end diff --git a/ee/spec/models/onboarding/user_onboarding_progress_spec.rb b/ee/spec/models/onboarding/user_onboarding_progress_spec.rb index 47e11a50f89186..707eebdd019f72 100644 --- a/ee/spec/models/onboarding/user_onboarding_progress_spec.rb +++ b/ee/spec/models/onboarding/user_onboarding_progress_spec.rb @@ -10,77 +10,28 @@ describe 'validations' do subject { build(:user_onboarding_progress) } - it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:task) } it { is_expected.to validate_uniqueness_of(:task).scoped_to(:user_id).ignoring_case_sensitivity } end describe 'enums' do - it 'defines task enum with prefix and correct values' do + it 'defines task enum and correct values' do is_expected.to define_enum_for(:task) - .with_prefix - .with_values( - merge_request_created: 0, - pipeline_created: 1, - user_added: 2, - trial_started: 3, - required_mr_approvals_enabled: 4, - code_owners_enabled: 5, - issue_created: 6, - secure_dependency_scanning_run: 7, - secure_dast_run: 8, - license_scanning_run: 9, - code_added: 10, - duo_seat_assigned: 11 - ) - end - end - - describe 'scopes' do - let_it_be(:user) { create(:user) } - let_it_be(:other_user) { create(:user) } - let_it_be(:user_mr_created) do - create(:user_onboarding_progress, user: user, task: :merge_request_created, created_at: 2.days.ago) - end - - let_it_be(:user_pipeline_created) do - create(:user_onboarding_progress, user: user, task: :pipeline_created, created_at: 1.day.ago) - end - - let_it_be(:other_user_mr_created) do - create(:user_onboarding_progress, user: other_user, task: :merge_request_created) - end - - describe '.for_user' do - it 'returns progress records for the specified user' do - results = described_class.for_user(user) - expect(results).to contain_exactly(user_mr_created, user_pipeline_created) - end - end - - describe '.by_completion' do - it 'orders records by created_at ascending' do - results = described_class.for_user(user).by_completion - expect(results).to contain_exactly(user_mr_created, user_pipeline_created) - end - end - - describe '.with_task' do - it 'returns records with the specified task' do - results = described_class.with_task(:merge_request_created) - expect(results).to contain_exactly(user_mr_created, other_user_mr_created) - end - end - end - - describe '#completed_at' do - it 'is an alias for created_at' do - expect(described_class.attribute_aliases['completed_at']).to eq('created_at') - end - - it 'returns the created_at timestamp' do - create(:user_onboarding_progress) - expect(described_class.instance_method(:completed_at).original_name).to eq(:created_at) + .with_values( + repository_created: 0, + merge_request_created: 1, + pipeline_created: 2, + user_added: 3, + trial_started: 4, + required_mr_approvals_enabled: 5, + code_owners_enabled: 6, + issue_created: 7, + secure_dependency_scanning_run: 8, + secure_dast_run: 9, + license_scanning_run: 10, + code_added: 11, + duo_seat_assigned: 12 + ) end end end -- GitLab From 2fafca2d41e29994005ea72d34b412dabd1701e0 Mon Sep 17 00:00:00 2001 From: Anas Shahid Date: Wed, 23 Jul 2025 00:45:23 -0400 Subject: [PATCH 3/3] Add foreign key constraint to user onboarding progresses table --- ...reign_key_to_user_onboarding_progresses.rb | 20 +++++++++++++++++++ db/schema_migrations/20250723043554 | 1 + db/structure.sql | 3 +++ 3 files changed, 24 insertions(+) create mode 100644 db/migrate/20250723043554_add_foreign_key_to_user_onboarding_progresses.rb create mode 100644 db/schema_migrations/20250723043554 diff --git a/db/migrate/20250723043554_add_foreign_key_to_user_onboarding_progresses.rb b/db/migrate/20250723043554_add_foreign_key_to_user_onboarding_progresses.rb new file mode 100644 index 00000000000000..5e98b1b4cf3602 --- /dev/null +++ b/db/migrate/20250723043554_add_foreign_key_to_user_onboarding_progresses.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddForeignKeyToUserOnboardingProgresses < Gitlab::Database::Migration[2.3] + milestone '18.3' + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :user_onboarding_progresses, :users, + column: :user_id, + target_column: :id, + validate: false, + on_delete: :cascade + end + + def down + with_lock_retries do + remove_foreign_key_if_exists :user_onboarding_progresses, :users, column: :user_id + end + end +end diff --git a/db/schema_migrations/20250723043554 b/db/schema_migrations/20250723043554 new file mode 100644 index 00000000000000..50a3585c1f8520 --- /dev/null +++ b/db/schema_migrations/20250723043554 @@ -0,0 +1 @@ +651709335facc3f18200e8483a549198dd530e2d61670ca226b3b76015edbed1 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index e99a1eafec33bc..38dcc0bf7cb2ee 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -43518,6 +43518,9 @@ ALTER TABLE ONLY path_locks ALTER TABLE ONLY agent_user_access_group_authorizations ADD CONSTRAINT fk_53fd98ccbf FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; +ALTER TABLE ONLY user_onboarding_progresses + ADD CONSTRAINT fk_544a52d439 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE NOT VALID; + ALTER TABLE ONLY group_crm_settings ADD CONSTRAINT fk_54592e5f57 FOREIGN KEY (source_group_id) REFERENCES namespaces(id) ON DELETE CASCADE; -- GitLab