diff --git a/apps/govquests-api/govquests/gamification/lib/gamification.rb b/apps/govquests-api/govquests/gamification/lib/gamification.rb index 32e0a39a..46ec9df4 100644 --- a/apps/govquests-api/govquests/gamification/lib/gamification.rb +++ b/apps/govquests-api/govquests/gamification/lib/gamification.rb @@ -4,6 +4,7 @@ require_relative "gamification/game_profile" require_relative "gamification/leaderboard" require_relative "gamification/badge" +require_relative "gamification/user_badge" ACTION_BADGE_NAMESPACE_UUID = "5FA78373-03E0-4D0B-91D1-3F2C6CA3F088" @@ -77,5 +78,9 @@ class CommandHandler < Infra::CommandHandlerRegistry handle "Gamification::CreateBadge", aggregate: Badge do |badge, cmd| badge.create(cmd.display_data, cmd.badgeable_id, cmd.badgeable_type) end + + handle "Gamification::EarnBadge", aggregate: UserBadge do |user_badge, cmd| + user_badge.earn_badge(cmd.user_id, cmd.badgeable_id, cmd.badgeable_type, cmd.earned_at) + end end end diff --git a/apps/govquests-api/govquests/gamification/lib/gamification/commands.rb b/apps/govquests-api/govquests/gamification/lib/gamification/commands.rb index af7e3245..2d8217dc 100644 --- a/apps/govquests-api/govquests/gamification/lib/gamification/commands.rb +++ b/apps/govquests-api/govquests/gamification/lib/gamification/commands.rb @@ -53,13 +53,6 @@ class MaintainStreak < Infra::Command alias_method :aggregate_id, :profile_id end - class EarnBadge < Infra::Command - attribute :profile_id, Infra::Types::UUID - attribute :badge, Infra::Types::String - - alias_method :aggregate_id, :profile_id - end - class UpdateLeaderboard < Infra::Command attribute :leaderboard_id, Infra::Types::UUID attribute :profile_id, Infra::Types::UUID @@ -76,4 +69,13 @@ class CreateBadge < Infra::Command alias_method :aggregate_id, :badge_id end + + class EarnBadge < Infra::Command + attribute :user_id, Infra::Types::UUID + attribute :badgeable_id, Infra::Types::String + attribute :badgeable_type, Infra::Types::String + attribute :earned_at, Infra::Types::DateTime.default { Time.current.to_datetime } + + alias_method :aggregate_id, :user_id + end end diff --git a/apps/govquests-api/govquests/gamification/lib/gamification/events.rb b/apps/govquests-api/govquests/gamification/lib/gamification/events.rb index 3fbabd63..c4ac2788 100644 --- a/apps/govquests-api/govquests/gamification/lib/gamification/events.rb +++ b/apps/govquests-api/govquests/gamification/lib/gamification/events.rb @@ -46,12 +46,7 @@ class StreakMaintained < Infra::Event attribute :profile_id, Infra::Types::UUID attribute :streak, Infra::Types::Integer end - - class BadgeEarned < Infra::Event - attribute :profile_id, Infra::Types::UUID - attribute :badge, Infra::Types::String - end - + class LeaderboardUpdated < Infra::Event attribute :leaderboard_id, Infra::Types::UUID attribute :profile_id, Infra::Types::UUID @@ -64,4 +59,11 @@ class BadgeCreated < Infra::Event attribute :badgeable_id, Infra::Types::UUID attribute :badgeable_type, Infra::Types::String end + + class BadgeEarned < Infra::Event + attribute :user_id, Infra::Types::UUID + attribute :badgeable_id, Infra::Types::String + attribute :badgeable_type, Infra::Types::String + attribute :earned_at, Infra::Types::DateTime + end end diff --git a/apps/govquests-api/govquests/gamification/lib/gamification/user_badge.rb b/apps/govquests-api/govquests/gamification/lib/gamification/user_badge.rb new file mode 100644 index 00000000..cd3fdf5b --- /dev/null +++ b/apps/govquests-api/govquests/gamification/lib/gamification/user_badge.rb @@ -0,0 +1,27 @@ +module Gamification + class UserBadge + include AggregateRoot + + def initialize(id) + @id = id + end + + def earn_badge(user_id, badgeable_id, badgeable_type, earned_at) + apply BadgeEarned.new(data: { + user_id:, + badgeable_id:, + badgeable_type:, + earned_at:, + }) + end + + private + + on BadgeEarned do |event| + @user_id = event.data[:user_id] + @badgeable_id = event.data[:badgeable_id] + @badgeable_type = event.data[:badgeable_type] + @earned_at = event.data[:earned_at] + end + end +end diff --git a/apps/govquests-api/govquests/processes/lib/processes.rb b/apps/govquests-api/govquests/processes/lib/processes.rb index dba79645..ec490c4c 100644 --- a/apps/govquests-api/govquests/processes/lib/processes.rb +++ b/apps/govquests-api/govquests/processes/lib/processes.rb @@ -10,6 +10,8 @@ require_relative "processes/notify_on_badge_earned" require_relative "processes/notify_on_tier_achieved" require_relative "processes/create_badge_on_track_or_quest_created" +require_relative "processes/reward_badge_on_quest_or_track_completed" +require_relative "processes/complete_track_on_quest_completed" require_relative "processes/deliver_notification_on_created" module Processes @@ -20,13 +22,15 @@ def call(event_store, command_bus) UpdateProfileOnRewardIssued.new(event_store, command_bus).subscribe DistributeRewardsOnQuestCompleted.new(event_store, command_bus).subscribe CreateBadgeOnTrackOrQuestCreated.new(event_store, command_bus).subscribe + RewardBadgeOnQuestOrTrackCompleted.new(event_store, command_bus).subscribe + CompleteTrackOnQuestCompleted.new(event_store, command_bus).subscribe NotifyOnQuestCompleted.new(event_store, command_bus).subscribe NotifyOnRewardIssued.new(event_store, command_bus).subscribe NotifyOnBadgeEarned.new(event_store, command_bus).subscribe NotifyOnTierAchieved.new(event_store, command_bus).subscribe - DeliverNotificationOnCreated.new(event_store, command_bus).subscribe + DeliverNotificationOnCreated.new(event_store, command_bus).subscribe end end end diff --git a/apps/govquests-api/govquests/processes/lib/processes/complete_track_on_quest_completed.rb b/apps/govquests-api/govquests/processes/lib/processes/complete_track_on_quest_completed.rb new file mode 100644 index 00000000..5ca39da6 --- /dev/null +++ b/apps/govquests-api/govquests/processes/lib/processes/complete_track_on_quest_completed.rb @@ -0,0 +1,38 @@ +module Processes + class CompleteTrackOnQuestCompleted + def initialize(event_store, command_bus) + @event_store = event_store + @command_bus = command_bus + end + + def subscribe + @event_store.subscribe(self, to: [Questing::QuestCompleted]) + end + + def call(event) + quest = Questing::QuestReadModel.find_by(quest_id: event.data[:quest_id]) + return unless quest&.track_id + + track = quest.track + user_id = event.data[:user_id] + + completed_quests_count = track.quests + .joins(:user_quests) + .where( + user_quests: { + user_id: user_id, + status: "completed" + } + ).count + + if completed_quests_count == track.quests.count + @command_bus.call( + Questing::CompleteTrack.new( + user_id: user_id, + track_id: track.track_id + ) + ) + end + end + end +end \ No newline at end of file diff --git a/apps/govquests-api/govquests/processes/lib/processes/create_badge_on_track_or_quest_created.rb b/apps/govquests-api/govquests/processes/lib/processes/create_badge_on_track_or_quest_created.rb index 260b9ac6..a6d5f640 100644 --- a/apps/govquests-api/govquests/processes/lib/processes/create_badge_on_track_or_quest_created.rb +++ b/apps/govquests-api/govquests/processes/lib/processes/create_badge_on_track_or_quest_created.rb @@ -10,19 +10,27 @@ def subscribe end def call(event) - display_data = event.data[:badge_display_data] - entity_type = event.class.name.split('::').last.gsub('Created', '') - entity_id = event.data["#{entity_type.downcase}_id".to_sym] + source_name = event.class.name.split('::').last.gsub('Created', '') + source_type = "::Questing::#{source_name}ReadModel" - badge_id = Gamification.generate_badge_id(entity_type, entity_id) + source_id_field = "#{source_name.downcase}_id".to_sym + source_uuid = event.data[source_id_field] + + source_sequential_id = source_type.constantize.find_by(source_id_field => source_uuid).id + + display_data = event.data[:badge_display_data].merge({ + sequence_number: source_sequential_id + }) + + badge_id = Gamification.generate_badge_id(source_type, source_uuid) @command_bus.call( ::Gamification::CreateBadge.new( badge_id:, display_data:, - badgeable_id: entity_id, - badgeable_type: entity_type + badgeable_id: source_uuid, + badgeable_type: source_name ) ) end diff --git a/apps/govquests-api/govquests/processes/lib/processes/notify_on_badge_earned.rb b/apps/govquests-api/govquests/processes/lib/processes/notify_on_badge_earned.rb index 91240dbd..217028d9 100644 --- a/apps/govquests-api/govquests/processes/lib/processes/notify_on_badge_earned.rb +++ b/apps/govquests-api/govquests/processes/lib/processes/notify_on_badge_earned.rb @@ -10,14 +10,18 @@ def subscribe end def call(event) - profile_id = event.data[:profile_id] - badge = event.data[:badge] + user_id = event.data[:user_id] + badgeable_id = event.data[:badgeable_id] + badgeable_type = event.data[:badgeable_type] + + badge_record = badgeable_type.constantize.find_by(id: badgeable_id) + badge_title = badge_record&.display_data&.dig("title") if badge_record @command_bus.call( ::Notifications::CreateNotification.new( notification_id: SecureRandom.uuid, - user_id: profile_id, - content: "Congratulations! You've earned the #{badge} badge!", + user_id: , + content: "Congratulations! You've earned the #{badge_title} badge!", notification_type: "badge_earned" ) ) diff --git a/apps/govquests-api/govquests/processes/lib/processes/reward_badge_on_quest_or_track_completed.rb b/apps/govquests-api/govquests/processes/lib/processes/reward_badge_on_quest_or_track_completed.rb new file mode 100644 index 00000000..4ed70bc8 --- /dev/null +++ b/apps/govquests-api/govquests/processes/lib/processes/reward_badge_on_quest_or_track_completed.rb @@ -0,0 +1,40 @@ +module Processes + class RewardBadgeOnQuestOrTrackCompleted + def initialize(event_store, command_bus) + @event_store = event_store + @command_bus = command_bus + end + + def subscribe + @event_store.subscribe(self, to: [::Questing::QuestCompleted, ::Questing::TrackCompleted]) + end + + def call(event) + user_id = event.data[:user_id] + + entity_name = event.class.name.split('::').last.gsub('Completed', '') + entity_type = case entity_name + when 'Quest' then 'Questing::QuestReadModel' + when 'Track' then 'Questing::TrackReadModel' + end + entity_id = event.data["#{entity_name.downcase}_id".to_sym] + + entity = entity_type.constantize.find_by("#{entity_name.downcase}_id": entity_id) + + badge = Gamification::BadgeReadModel.find_by( + badgeable_type: entity_type, + badgeable_id: entity.id.to_s, + ) + + return unless badge + + @command_bus.call( + ::Gamification::EarnBadge.new( + user_id:, + badgeable_id: badge.id.to_s, + badgeable_type: badge.class.name, + ) + ) + end + end +end diff --git a/apps/govquests-api/govquests/questing/lib/questing.rb b/apps/govquests-api/govquests/questing/lib/questing.rb index 99c2c628..b424a15f 100644 --- a/apps/govquests-api/govquests/questing/lib/questing.rb +++ b/apps/govquests-api/govquests/questing/lib/questing.rb @@ -59,5 +59,9 @@ class CommandHandler < Infra::CommandHandlerRegistry badge_display_data: cmd.badge_display_data ) end + + handle "Questing::CompleteTrack", aggregate: Track do |track, cmd| + track.complete(user_id: cmd.user_id) + end end end diff --git a/apps/govquests-api/govquests/questing/lib/questing/commands.rb b/apps/govquests-api/govquests/questing/lib/questing/commands.rb index 7109332a..c20b3998 100644 --- a/apps/govquests-api/govquests/questing/lib/questing/commands.rb +++ b/apps/govquests-api/govquests/questing/lib/questing/commands.rb @@ -57,4 +57,11 @@ class CreateTrack < Infra::Command alias_method :aggregate_id, :track_id end + + class CompleteTrack < Infra::Command + attribute :user_id, Infra::Types::UUID + attribute :track_id, Infra::Types::UUID + + alias_method :aggregate_id, :track_id + end end diff --git a/apps/govquests-api/govquests/questing/lib/questing/events.rb b/apps/govquests-api/govquests/questing/lib/questing/events.rb index edca5617..c047a9e5 100644 --- a/apps/govquests-api/govquests/questing/lib/questing/events.rb +++ b/apps/govquests-api/govquests/questing/lib/questing/events.rb @@ -43,4 +43,9 @@ class TrackCreated < Infra::Event attribute :quest_ids, Infra::Types::Array attribute :badge_display_data, Infra::Types::Hash end + + class TrackCompleted < Infra::Event + attribute :user_id, Infra::Types::UUID + attribute :track_id, Infra::Types::UUID + end end diff --git a/apps/govquests-api/govquests/questing/lib/questing/track.rb b/apps/govquests-api/govquests/questing/lib/questing/track.rb index 73ab3566..9595f8a5 100644 --- a/apps/govquests-api/govquests/questing/lib/questing/track.rb +++ b/apps/govquests-api/govquests/questing/lib/questing/track.rb @@ -24,10 +24,20 @@ def create(display_data:, quest_ids:, badge_display_data:) ) end + def complete(user_id:) + apply(TrackCompleted.new(data: { + user_id:, + track_id: @id + })) + end + on TrackCreated do |event| @display_data = event.data[:display_data] @quest_ids = event.data[:quest_ids] @badge_display_data = event.data[:badge_display_data] end + + on TrackCompleted do |event| + end end end diff --git a/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_badges.rb b/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_badges.rb index 375dc269..db828596 100644 --- a/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_badges.rb +++ b/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_badges.rb @@ -2,17 +2,24 @@ module Resolvers class FetchBadges < BaseResolver type [Types::BadgeType], null: true - argument :special, Boolean, required: false, default_value: nil + def resolve + current_user = context[:current_user] + badges = Gamification::BadgeReadModel.includes(:user_badges) - def resolve(special:) - case special - when true - Gamification::BadgeReadModel.special - when false - Gamification::BadgeReadModel.normal - else - Gamification::BadgeReadModel.all - end + return badges unless current_user + + earned_badge_ids = Gamification::UserBadgeReadModel + .where(user_id: current_user.user_id) + .where(badgeable_type: "Gamification::BadgeReadModel") + .pluck(:badgeable_id) + + return badges if earned_badge_ids.empty? + + badges.order( + Arel.sql( + "CASE WHEN badges.id IN (#{earned_badge_ids.join(",")}) THEN 0 ELSE 1 END" + ) + ) end end end diff --git a/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_special_badge.rb b/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_special_badge.rb new file mode 100644 index 00000000..10245549 --- /dev/null +++ b/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_special_badge.rb @@ -0,0 +1,11 @@ +module Resolvers + class FetchSpecialBadge < BaseResolver + type Types::SpecialBadgeType, null: true + + argument :id, ID, required: true + + def resolve(id:) + Gamification::SpecialBadgeReadModel.find_by(badge_id: id) + end + end +end diff --git a/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_special_badges.rb b/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_special_badges.rb new file mode 100644 index 00000000..677c69a2 --- /dev/null +++ b/apps/govquests-api/rails_app/app/graphql/resolvers/fetch_special_badges.rb @@ -0,0 +1,25 @@ +module Resolvers + class FetchSpecialBadges < BaseResolver + type [Types::SpecialBadgeType], null: true + + def resolve + current_user = context[:current_user] + badges = Gamification::SpecialBadgeReadModel.includes(:user_badges) + + return badges unless current_user + + earned_badge_ids = Gamification::UserBadgeReadModel + .where(user_id: current_user.user_id) + .where(badgeable_type: "Gamification::SpecialBadgeReadModel") + .pluck(:badgeable_id) + + return badges if earned_badge_ids.empty? + + badges.order( + Arel.sql( + "CASE WHEN special_badges.id IN (#{earned_badge_ids.join(",")}) THEN 0 ELSE 1 END" + ) + ) + end + end +end diff --git a/apps/govquests-api/rails_app/app/graphql/types/badge_display_data_type.rb b/apps/govquests-api/rails_app/app/graphql/types/badge_display_data_type.rb index 6430f767..b42c467a 100644 --- a/apps/govquests-api/rails_app/app/graphql/types/badge_display_data_type.rb +++ b/apps/govquests-api/rails_app/app/graphql/types/badge_display_data_type.rb @@ -3,5 +3,6 @@ class BadgeDisplayDataType < Types::BaseObject field :title, String, null: true field :description, String, null: true field :image_url, String, null: true + field :sequence_number, Integer, null: true end end diff --git a/apps/govquests-api/rails_app/app/graphql/types/badge_type.rb b/apps/govquests-api/rails_app/app/graphql/types/badge_type.rb index f1a08c90..765eef1e 100644 --- a/apps/govquests-api/rails_app/app/graphql/types/badge_type.rb +++ b/apps/govquests-api/rails_app/app/graphql/types/badge_type.rb @@ -3,10 +3,15 @@ class BadgeType < Types::BaseObject field :id, ID, null: false, method: :badge_id field :display_data, Types::BadgeDisplayDataType, null: false field :badgeable, Types::BadgeableUnion, null: true - field :special, Boolean, null: + field :user_badges, [Types::UserBadgeType], null: false + field :earned_by_current_user, Boolean, null: false - def special - object.special? + def user_badges + object.user_badges.where(user: context[:current_user]) + end + + def earned_by_current_user + object.user_badges.exists?(user: context[:current_user]) end end end diff --git a/apps/govquests-api/rails_app/app/graphql/types/query_type.rb b/apps/govquests-api/rails_app/app/graphql/types/query_type.rb index 1fa9abdc..6d522dd4 100644 --- a/apps/govquests-api/rails_app/app/graphql/types/query_type.rb +++ b/apps/govquests-api/rails_app/app/graphql/types/query_type.rb @@ -29,6 +29,9 @@ def nodes(ids:) field :badge, resolver: Resolvers::FetchBadge field :badges, resolver: Resolvers::FetchBadges + field :special_badge, resolver: Resolvers::FetchSpecialBadge + field :special_badges, resolver: Resolvers::FetchSpecialBadges + field :current_user, resolver: Resolvers::CurrentUser, preauthorize: {with: AuthenticatedGraphqlPolicy} field :notifications, diff --git a/apps/govquests-api/rails_app/app/graphql/types/special_badge_type.rb b/apps/govquests-api/rails_app/app/graphql/types/special_badge_type.rb new file mode 100644 index 00000000..faab4e05 --- /dev/null +++ b/apps/govquests-api/rails_app/app/graphql/types/special_badge_type.rb @@ -0,0 +1,18 @@ +module Types + class SpecialBadgeType < Types::BaseObject + field :id, ID, null: false, method: :badge_id + field :display_data, Types::BadgeDisplayDataType, null: false + field :points, Integer, null: false + field :badge_type, String, null: false + field :user_badges, [Types::UserBadgeType], null: false + field :earned_by_current_user, Boolean, null: false + + def user_badges + object.user_badges.where(user: context[:current_user]) + end + + def earned_by_current_user + object.user_badges.exists?(user: context[:current_user]) + end + end +end diff --git a/apps/govquests-api/rails_app/app/graphql/types/user_badge_type.rb b/apps/govquests-api/rails_app/app/graphql/types/user_badge_type.rb new file mode 100644 index 00000000..04cd32d7 --- /dev/null +++ b/apps/govquests-api/rails_app/app/graphql/types/user_badge_type.rb @@ -0,0 +1,6 @@ +module Types + class UserBadgeType < Types::BaseObject + field :user_id, ID, null: false, method: :user_id + field :earned_at, GraphQL::Types::ISO8601DateTime, null: false + end +end diff --git a/apps/govquests-api/rails_app/app/read_models/gamification/badge_read_model.rb b/apps/govquests-api/rails_app/app/read_models/gamification/badge_read_model.rb index 884a5b93..69aafedd 100644 --- a/apps/govquests-api/rails_app/app/read_models/gamification/badge_read_model.rb +++ b/apps/govquests-api/rails_app/app/read_models/gamification/badge_read_model.rb @@ -2,27 +2,14 @@ module Gamification class BadgeReadModel < ApplicationRecord self.table_name = "badges" - belongs_to :badgeable, polymorphic: true, optional: true + belongs_to :badgeable, polymorphic: true validates :badge_id, presence: true, uniqueness: true validates :display_data, presence: true - validate :validate_badgeable_consistency - def special? - badgeable_id.nil? && badgeable_type.nil? - end - - scope :special, -> { where(badgeable_type: nil, badgeable_id: nil) } - scope :normal, -> { where.not(badgeable_type: nil, badgeable_id: nil) } - - private - - def validate_badgeable_consistency - if (badgeable_type.present? && badgeable_id.nil?) || - (badgeable_type.nil? && badgeable_id.present?) - errors.add(:base, "Both badgeable type and ID must be present, or both must be nil") - end - end + has_many :user_badges, + class_name: "Gamification::UserBadgeReadModel", + as: :badgeable end end @@ -31,15 +18,15 @@ def validate_badgeable_consistency # Table name: badges # # id :bigint not null, primary key -# badgeable_type :string +# badgeable_type :string not null # display_data :jsonb not null # created_at :datetime not null # updated_at :datetime not null # badge_id :string not null -# badgeable_id :string +# badgeable_id :string not null # # Indexes # # index_badges_on_badge_id (badge_id) UNIQUE -# index_badges_on_badgeable_type_and_badgeable_id (badgeable_type,badgeable_id) UNIQUE WHERE ((badgeable_type IS NOT NULL) AND (badgeable_id IS NOT NULL)) +# index_badges_on_badgeable_type_and_badgeable_id (badgeable_type,badgeable_id) UNIQUE # diff --git a/apps/govquests-api/rails_app/app/read_models/gamification/on_badge_earned.rb b/apps/govquests-api/rails_app/app/read_models/gamification/on_badge_earned.rb index 64d6f442..cb65f98d 100644 --- a/apps/govquests-api/rails_app/app/read_models/gamification/on_badge_earned.rb +++ b/apps/govquests-api/rails_app/app/read_models/gamification/on_badge_earned.rb @@ -1,21 +1,24 @@ module Gamification class OnBadgeEarned def call(event) - profile_id = event.data[:profile_id] - badge = event.data[:badge] + user_id = event.data[:user_id] + badgeable_id = event.data[:badgeable_id] + badgeable_type = event.data[:badgeable_type] + earned_at = event.data[:earned_at] - game_profile = GameProfileReadModel.find_by(profile_id: profile_id) - if game_profile - badges = game_profile.badges || [] - if badges.include?(badge) - Rails.logger.info "Badge '#{badge}' already exists for GameProfile #{profile_id}" - else - game_profile.update(badges: badges + [badge]) - Rails.logger.info "Added badge '#{badge}' to GameProfile #{profile_id}" - end - else - Rails.logger.warn "GameProfile #{profile_id} not found for BadgeEarned event" - end + return if badgeable_type == "Gamification::BadgeReadModel" && + UserBadgeReadModel.exists?( + user_id:, + badgeable_id: badgeable_id, + badgeable_type: badgeable_type + ) + + UserBadgeReadModel.create!( + user_id:, + badgeable_id: badgeable_id, + badgeable_type: badgeable_type, + earned_at: earned_at + ) end end end diff --git a/apps/govquests-api/rails_app/app/read_models/gamification/special_badge_read_model.rb b/apps/govquests-api/rails_app/app/read_models/gamification/special_badge_read_model.rb new file mode 100644 index 00000000..01b1866b --- /dev/null +++ b/apps/govquests-api/rails_app/app/read_models/gamification/special_badge_read_model.rb @@ -0,0 +1,33 @@ +module Gamification + class SpecialBadgeReadModel < ApplicationRecord + self.table_name = "special_badges" + + validates :badge_id, presence: true, uniqueness: true + validates :display_data, presence: true + validates :badge_type, presence: true + validates :badge_data, presence: true + validates :points, presence: true, numericality: {only_integer: true, greater_than_or_equal_to: 0} + + has_many :user_badges, + class_name: "Gamification::UserBadgeReadModel", + as: :badgeable + end +end + +# == Schema Information +# +# Table name: special_badges +# +# id :bigint not null, primary key +# badge_data :jsonb not null +# badge_type :string not null +# display_data :jsonb not null +# points :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# badge_id :string not null +# +# Indexes +# +# index_special_badges_on_badge_id (badge_id) UNIQUE +# diff --git a/apps/govquests-api/rails_app/app/read_models/gamification/user_badge_read_model.rb b/apps/govquests-api/rails_app/app/read_models/gamification/user_badge_read_model.rb new file mode 100644 index 00000000..e57b98d4 --- /dev/null +++ b/apps/govquests-api/rails_app/app/read_models/gamification/user_badge_read_model.rb @@ -0,0 +1,43 @@ +module Gamification + class UserBadgeReadModel < ApplicationRecord + self.table_name = "user_badges" + + belongs_to :user, class_name: "Authentication::UserReadModel", foreign_key: "user_id", primary_key: "user_id" + belongs_to :badgeable, polymorphic: true + + validates :user_id, presence: true + validates :earned_at, presence: true + + scope :special, -> { where(badgeable_type: "Gamification::SpecialBadgeReadModel") } + scope :normal, -> { where(badgeable_type: "Gamification::BadgeReadModel") } + scope :earned_between, ->(start_date, end_date) { where(earned_at: start_date..end_date) } + scope :ordered_by_earned, -> { order(earned_at: :desc) } + + def special? + badgeable_type == "Gamification::SpecialBadgeReadModel" + end + + def normal? + badgeable_type == "Gamification::BadgeReadModel" + end + end +end + +# == Schema Information +# +# Table name: user_badges +# +# id :bigint not null, primary key +# badgeable_type :string not null +# earned_at :datetime not null +# created_at :datetime not null +# updated_at :datetime not null +# badgeable_id :string not null +# user_id :string not null +# +# Indexes +# +# index_user_badges_on_badgeable_type_and_badgeable_id (badgeable_type,badgeable_id) +# index_user_badges_on_earned_at (earned_at) +# unique_normal_badges_index (user_id,badgeable_type,badgeable_id) UNIQUE WHERE ((badgeable_type)::text = 'Gamification::BadgeReadModel'::text) +# diff --git a/apps/govquests-api/rails_app/app/read_models/questing/on_track_completed.rb b/apps/govquests-api/rails_app/app/read_models/questing/on_track_completed.rb new file mode 100644 index 00000000..97becdd6 --- /dev/null +++ b/apps/govquests-api/rails_app/app/read_models/questing/on_track_completed.rb @@ -0,0 +1,13 @@ +module Questing + class OnTrackCompleted + def call(event) + UserTrackReadModel.create_or_find_by!( + user_id: event.data[:user_id], + track_id: event.data[:track_id] + ).update!( + status: "completed", + completed_at: Time.current + ) + end + end +end diff --git a/apps/govquests-api/rails_app/app/read_models/questing/on_track_created.rb b/apps/govquests-api/rails_app/app/read_models/questing/on_track_created.rb index 0d02e479..b5f101df 100644 --- a/apps/govquests-api/rails_app/app/read_models/questing/on_track_created.rb +++ b/apps/govquests-api/rails_app/app/read_models/questing/on_track_created.rb @@ -1,11 +1,16 @@ module Questing class OnTrackCreated def call(event) - TrackReadModel.create!( + track = TrackReadModel.create!( track_id: event.data[:track_id], - display_data: event.data[:display_data], - quest_ids: event.data[:quest_ids] + display_data: event.data[:display_data] ) + + event.data[:quest_ids].each_with_index do |quest_id, index| + QuestReadModel.find_by(quest_id: quest_id)&.update!( + track_id: track.track_id + ) + end end end end diff --git a/apps/govquests-api/rails_app/app/read_models/questing/quest_read_model.rb b/apps/govquests-api/rails_app/app/read_models/questing/quest_read_model.rb index 5f703fb3..d66f7cbf 100644 --- a/apps/govquests-api/rails_app/app/read_models/questing/quest_read_model.rb +++ b/apps/govquests-api/rails_app/app/read_models/questing/quest_read_model.rb @@ -7,6 +7,12 @@ class QuestReadModel < ApplicationRecord has_many :user_quests, class_name: "Questing::UserQuestReadModel", foreign_key: "quest_id", primary_key: "quest_id" has_many :reward_pools, class_name: "Rewarding::RewardPoolReadModel", foreign_key: "quest_id", primary_key: "quest_id" + belongs_to :track, + class_name: "Questing::TrackReadModel", + foreign_key: "track_id", + primary_key: "track_id", + optional: true + validates :quest_id, presence: true, uniqueness: true validates :slug, presence: true validates :audience, presence: true @@ -31,8 +37,10 @@ class QuestReadModel < ApplicationRecord # created_at :datetime not null # updated_at :datetime not null # quest_id :string not null +# track_id :string # # Indexes # # index_quests_on_quest_id (quest_id) UNIQUE +# index_quests_on_track_id (track_id) # diff --git a/apps/govquests-api/rails_app/app/read_models/questing/read_model_configuration.rb b/apps/govquests-api/rails_app/app/read_models/questing/read_model_configuration.rb index 5adc752f..2aa051e4 100644 --- a/apps/govquests-api/rails_app/app/read_models/questing/read_model_configuration.rb +++ b/apps/govquests-api/rails_app/app/read_models/questing/read_model_configuration.rb @@ -6,6 +6,7 @@ def call(event_store) event_store.subscribe(OnQuestStarted, to: [Questing::QuestStarted]) event_store.subscribe(OnQuestCompleted, to: [Questing::QuestCompleted]) event_store.subscribe(OnTrackCreated, to: [Questing::TrackCreated]) + event_store.subscribe(OnTrackCompleted, to: [Questing::TrackCompleted]) end end end diff --git a/apps/govquests-api/rails_app/app/read_models/questing/track_read_model.rb b/apps/govquests-api/rails_app/app/read_models/questing/track_read_model.rb index 61a20dae..65ff5847 100644 --- a/apps/govquests-api/rails_app/app/read_models/questing/track_read_model.rb +++ b/apps/govquests-api/rails_app/app/read_models/questing/track_read_model.rb @@ -2,15 +2,19 @@ module Questing class TrackReadModel < ApplicationRecord self.table_name = "tracks" - attribute :quest_ids, :jsonb, array: true + has_many :quests, + class_name: "Questing::QuestReadModel", + foreign_key: "track_id", + primary_key: "track_id" + + has_many :user_tracks, + class_name: "Questing::UserTrackReadModel", + foreign_key: "track_id", + primary_key: "track_id" validates :track_id, presence: true, uniqueness: true validates :display_data, presence: true - def quests - QuestReadModel.where(quest_id: quest_ids) - end - has_one :badge, class_name: "Gamification::BadgeReadModel", as: :badgeable diff --git a/apps/govquests-api/rails_app/app/read_models/questing/user_track_read_model.rb b/apps/govquests-api/rails_app/app/read_models/questing/user_track_read_model.rb new file mode 100644 index 00000000..a3eb3596 --- /dev/null +++ b/apps/govquests-api/rails_app/app/read_models/questing/user_track_read_model.rb @@ -0,0 +1,43 @@ +module Questing + class UserTrackReadModel < ApplicationRecord + self.table_name = "user_tracks" + + belongs_to :user, + class_name: "Authentication::UserReadModel", + foreign_key: "user_id", + primary_key: "user_id" + + belongs_to :track, + class_name: "Questing::TrackReadModel", + foreign_key: "track_id", + primary_key: "track_id" + + validates :user_id, presence: true + validates :track_id, presence: true + validates :completed_at, presence: true, if: :completed? + validates :user_id, uniqueness: {scope: :track_id} + + def completed? + status == "completed" + end + end +end + +# == Schema Information +# +# Table name: user_tracks +# +# id :bigint not null, primary key +# completed_at :datetime +# status :string default("in_progress"), not null +# created_at :datetime not null +# updated_at :datetime not null +# track_id :string not null +# user_id :string not null +# +# Indexes +# +# index_user_tracks_on_status (status) +# index_user_tracks_on_track_id (track_id) +# index_user_tracks_on_user_id_and_track_id (user_id,track_id) UNIQUE +# diff --git a/apps/govquests-api/rails_app/db/migrate/20250115203038_make_badgeable_optional_in_badges.rb b/apps/govquests-api/rails_app/db/migrate/20250115203038_make_badgeable_optional_in_badges.rb deleted file mode 100644 index ef961ce8..00000000 --- a/apps/govquests-api/rails_app/db/migrate/20250115203038_make_badgeable_optional_in_badges.rb +++ /dev/null @@ -1,11 +0,0 @@ -class MakeBadgeableOptionalInBadges < ActiveRecord::Migration[8.1] - def change - change_column_null :badges, :badgeable_type, true - change_column_null :badges, :badgeable_id, true - - remove_index :badges, [:badgeable_type, :badgeable_id] - add_index :badges, [:badgeable_type, :badgeable_id], - unique: true, - where: "badgeable_type IS NOT NULL AND badgeable_id IS NOT NULL" - end -end diff --git a/apps/govquests-api/rails_app/db/migrate/20250116122834_create_special_badges.rb b/apps/govquests-api/rails_app/db/migrate/20250116122834_create_special_badges.rb new file mode 100644 index 00000000..7ef5f16a --- /dev/null +++ b/apps/govquests-api/rails_app/db/migrate/20250116122834_create_special_badges.rb @@ -0,0 +1,14 @@ +class CreateSpecialBadges < ActiveRecord::Migration[8.1] + def change + create_table :special_badges do |t| + t.string :badge_id, null: false + t.jsonb :display_data, null: false + t.string :badge_type, null: false + t.jsonb :badge_data, null: false + t.integer :points, null: false + t.timestamps + end + + add_index :special_badges, :badge_id, unique: true + end +end diff --git a/apps/govquests-api/rails_app/db/migrate/20250116135942_create_user_badges.rb b/apps/govquests-api/rails_app/db/migrate/20250116135942_create_user_badges.rb new file mode 100644 index 00000000..d0dadd81 --- /dev/null +++ b/apps/govquests-api/rails_app/db/migrate/20250116135942_create_user_badges.rb @@ -0,0 +1,20 @@ +class CreateUserBadges < ActiveRecord::Migration[8.1] + def change + create_table :user_badges do |t| + t.string :user_id, null: false + t.string :badgeable_type, null: false + t.string :badgeable_id, null: false + t.datetime :earned_at, null: false + t.timestamps + end + + add_index :user_badges, [:badgeable_type, :badgeable_id] + add_index :user_badges, :earned_at + + # Optional: Add a partial unique index for normal badges + add_index :user_badges, [:user_id, :badgeable_type, :badgeable_id], + unique: true, + where: "badgeable_type = 'Gamification::BadgeReadModel'", + name: 'unique_normal_badges_index' + end +end diff --git a/apps/govquests-api/rails_app/db/migrate/20250116195445_create_user_tracks.rb b/apps/govquests-api/rails_app/db/migrate/20250116195445_create_user_tracks.rb new file mode 100644 index 00000000..544e727d --- /dev/null +++ b/apps/govquests-api/rails_app/db/migrate/20250116195445_create_user_tracks.rb @@ -0,0 +1,15 @@ +class CreateUserTracks < ActiveRecord::Migration[8.1] + def change + create_table :user_tracks do |t| + t.string :track_id, null: false + t.string :user_id, null: false + t.string :status, null: false, default: 'in_progress' + t.datetime :completed_at + t.timestamps + end + + add_index :user_tracks, [:user_id, :track_id], unique: true + add_index :user_tracks, :track_id + add_index :user_tracks, :status + end +end diff --git a/apps/govquests-api/rails_app/db/migrate/20250116200408_add_track_id_to_quests.rb b/apps/govquests-api/rails_app/db/migrate/20250116200408_add_track_id_to_quests.rb new file mode 100644 index 00000000..dbcec008 --- /dev/null +++ b/apps/govquests-api/rails_app/db/migrate/20250116200408_add_track_id_to_quests.rb @@ -0,0 +1,7 @@ +class AddTrackIdToQuests < ActiveRecord::Migration[8.1] + def change + add_column :quests, :track_id, :string + + add_index :quests, :track_id + end +end diff --git a/apps/govquests-api/rails_app/db/schema.rb b/apps/govquests-api/rails_app/db/schema.rb index 7f7bb98f..00771fec 100644 --- a/apps/govquests-api/rails_app/db/schema.rb +++ b/apps/govquests-api/rails_app/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_01_15_203038) do +ActiveRecord::Schema[8.1].define(version: 2025_01_16_200408) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -47,10 +47,10 @@ t.jsonb "display_data", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "badgeable_type" - t.string "badgeable_id" + t.string "badgeable_type", null: false + t.string "badgeable_id", null: false t.index ["badge_id"], name: "index_badges_on_badge_id", unique: true - t.index ["badgeable_type", "badgeable_id"], name: "index_badges_on_badgeable_type_and_badgeable_id", unique: true, where: "((badgeable_type IS NOT NULL) AND (badgeable_id IS NOT NULL))" + t.index ["badgeable_type", "badgeable_id"], name: "index_badges_on_badgeable_type_and_badgeable_id", unique: true end create_table "event_store_events", force: :cascade do |t| @@ -137,7 +137,9 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "slug" + t.string "track_id" t.index ["quest_id"], name: "index_quests_on_quest_id", unique: true + t.index ["track_id"], name: "index_quests_on_track_id" end create_table "reward_issuances", force: :cascade do |t| @@ -178,6 +180,17 @@ t.index ["reward_id"], name: "index_rewards_on_reward_id", unique: true end + create_table "special_badges", force: :cascade do |t| + t.string "badge_id", null: false + t.jsonb "display_data", null: false + t.string "badge_type", null: false + t.jsonb "badge_data", null: false + t.integer "points", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["badge_id"], name: "index_special_badges_on_badge_id", unique: true + end + create_table "tracks", force: :cascade do |t| t.string "track_id", null: false t.jsonb "display_data", default: {}, null: false @@ -187,6 +200,18 @@ t.index ["track_id"], name: "index_tracks_on_track_id", unique: true end + create_table "user_badges", force: :cascade do |t| + t.string "user_id", null: false + t.string "badgeable_type", null: false + t.string "badgeable_id", null: false + t.datetime "earned_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["badgeable_type", "badgeable_id"], name: "index_user_badges_on_badgeable_type_and_badgeable_id" + t.index ["earned_at"], name: "index_user_badges_on_earned_at" + t.index ["user_id", "badgeable_type", "badgeable_id"], name: "unique_normal_badges_index", unique: true, where: "((badgeable_type)::text = 'Gamification::BadgeReadModel'::text)" + end + create_table "user_game_profiles", force: :cascade do |t| t.string "profile_id", null: false t.integer "tier", default: 0 @@ -234,6 +259,18 @@ t.index ["user_id"], name: "index_user_sessions_on_user_id" end + create_table "user_tracks", force: :cascade do |t| + t.string "track_id", null: false + t.string "user_id", null: false + t.string "status", default: "in_progress", null: false + t.datetime "completed_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status"], name: "index_user_tracks_on_status" + t.index ["track_id"], name: "index_user_tracks_on_track_id" + t.index ["user_id", "track_id"], name: "index_user_tracks_on_user_id_and_track_id", unique: true + end + create_table "users", force: :cascade do |t| t.string "user_id", null: false t.string "email" diff --git a/apps/govquests-api/rails_app/schema.graphql b/apps/govquests-api/rails_app/schema.graphql index 04a34351..18b7c7fd 100644 --- a/apps/govquests-api/rails_app/schema.graphql +++ b/apps/govquests-api/rails_app/schema.graphql @@ -57,13 +57,15 @@ type BackgroundGradient { type Badge { badgeable: BadgeableUnion displayData: BadgeDisplayData! + earnedByCurrentUser: Boolean! id: ID! - special: Boolean + userBadges: [UserBadge!]! } type BadgeDisplayData { description: String imageUrl: String + sequenceNumber: Int title: String } @@ -524,7 +526,7 @@ type PageInfo { type Query { badge(id: ID!): Badge - badges(special: Boolean): [Badge!] + badges: [Badge!] currentUser: User notifications( """ @@ -551,6 +553,8 @@ type Query { ): NotificationConnection! quest(slug: String!): Quest quests: [Quest!]! + specialBadge(id: ID!): SpecialBadge + specialBadges: [SpecialBadge!] track(id: ID!): Track tracks: [Track!] unreadNotificationsCount: Int! @@ -651,6 +655,15 @@ type SignOutPayload { success: Boolean! } +type SpecialBadge { + badgeType: String! + displayData: BadgeDisplayData! + earnedByCurrentUser: Boolean! + id: ID! + points: Int! + userBadges: [UserBadge!]! +} + """ Autogenerated input type of StartActionExecution """ @@ -716,6 +729,11 @@ type User { userType: String! } +type UserBadge { + earnedAt: ISO8601DateTime! + userId: ID! +} + type UserQuest { completedAt: ISO8601DateTime id: ID! diff --git a/apps/govquests-frontend/src/domains/action_tracking/hooks/useCompleteActionExecution.ts b/apps/govquests-frontend/src/domains/action_tracking/hooks/useCompleteActionExecution.ts index d0161e87..de4dfd75 100644 --- a/apps/govquests-frontend/src/domains/action_tracking/hooks/useCompleteActionExecution.ts +++ b/apps/govquests-frontend/src/domains/action_tracking/hooks/useCompleteActionExecution.ts @@ -15,7 +15,9 @@ export const useCompleteActionExecution = (invalidateKey: string[]) => { mutationFn: completeActionExecution, onSuccess: () => { queryClient.invalidateQueries({ queryKey: invalidateKey }); - queryClient.invalidateQueries({ queryKey: ["notifications"] }); + queryClient.invalidateQueries({ queryKey: ["notifications"] }); + queryClient.invalidateQueries({ queryKey: ["badge"] }); + }, }); }; diff --git a/apps/govquests-frontend/src/domains/action_tracking/hooks/useStartActionExecution.ts b/apps/govquests-frontend/src/domains/action_tracking/hooks/useStartActionExecution.ts index e013af64..a73f7fe5 100644 --- a/apps/govquests-frontend/src/domains/action_tracking/hooks/useStartActionExecution.ts +++ b/apps/govquests-frontend/src/domains/action_tracking/hooks/useStartActionExecution.ts @@ -15,6 +15,7 @@ export const useStartActionExecution = () => { mutationFn: startActionExecution, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notifications"] }); + queryClient.invalidateQueries({ queryKey: ["badge"] }); }, }); }; diff --git a/apps/govquests-frontend/src/domains/gamification/components/BadgeCard.tsx b/apps/govquests-frontend/src/domains/gamification/components/BadgeCard.tsx index c3ec71b6..ce4fca3a 100644 --- a/apps/govquests-frontend/src/domains/gamification/components/BadgeCard.tsx +++ b/apps/govquests-frontend/src/domains/gamification/components/BadgeCard.tsx @@ -7,20 +7,21 @@ import { useFetchBadge } from "../hooks/useFetchBadge"; interface BadgeCardProps { badgeId: string; - isCompleted: boolean; className?: string; withTitle?: boolean; header?: string; + revealIncomplete?: boolean; } export const BadgeCard = ({ badgeId, - isCompleted, className, withTitle = false, header, + revealIncomplete = false, }: BadgeCardProps) => { const { data } = useFetchBadge(badgeId); + const revealCard = data?.badge.earnedByCurrentUser || revealIncomplete; const Card = data && (