Skip to content

Commit 242d4e4

Browse files
etewiahclaude
andcommitted
Add HPG game engine API — models, controllers, serializers, and specs (Phases 0–3)
Implements the full HousePriceGuess backend integration: - Phase 0: CORS for housepriceguess.com, hpg integration category - Phase 1: 5 new tables (realty_games, game_listings, game_sessions, game_estimates, access_codes) with models, validations, scopes, and callbacks - Phase 2: 7 API endpoints under api_public/v1/hpg/ (games, estimates, results, leaderboards, access_codes, listings) with 4 serializers/services - Phase 3: Provisioning rake tasks (hpg:provision, hpg:provision_batch, hpg:create_sample_game) 103 specs passing (57 model + 46 request) including cross-tenant isolation tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0e83986 commit 242d4e4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+2739
-1
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class AccessCodesController < BaseController
7+
# POST /api_public/v1/hpg/access_codes/check
8+
def check
9+
disable_cache!
10+
11+
code = params[:code].to_s.strip
12+
valid = current_website.access_codes.valid.exists?(code: code)
13+
14+
render json: { valid: valid, code: code }
15+
end
16+
end
17+
end
18+
end
19+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class BaseController < ApiPublic::V1::BaseController
7+
private
8+
9+
# Override: shorter default cache for game data
10+
def set_api_public_cache_headers
11+
expires_in 5.minutes, public: true
12+
response.headers["Vary"] = "X-Website-Slug"
13+
end
14+
15+
def disable_cache!
16+
response.headers["Cache-Control"] = "no-store"
17+
end
18+
19+
def find_game!
20+
@game = current_website.realty_games.find_by!(slug: params[:slug])
21+
end
22+
23+
def require_website!
24+
return if current_website.present?
25+
26+
render json: {
27+
error: { code: 'WEBSITE_REQUIRED', message: 'Unable to determine website from request.' }
28+
}, status: :bad_request
29+
end
30+
end
31+
end
32+
end
33+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class EstimatesController < BaseController
7+
before_action :find_game!
8+
9+
# POST /api_public/v1/hpg/games/:slug/estimates
10+
def create
11+
disable_cache!
12+
13+
result = Pwb::Hpg::EstimateProcessor.call(
14+
game: @game,
15+
website: current_website,
16+
params: estimate_params
17+
)
18+
19+
if result[:error]
20+
render json: { error: result[:error] }, status: result[:status]
21+
else
22+
render json: result[:data], status: :created
23+
end
24+
end
25+
26+
private
27+
28+
def estimate_params
29+
params.require(:price_estimate).permit(
30+
:game_listing_id, :estimated_price, :currency,
31+
:visitor_token, :guest_name, :property_index, :session_id
32+
)
33+
end
34+
end
35+
end
36+
end
37+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class GamesController < BaseController
7+
before_action :find_game!, only: :show
8+
9+
# GET /api_public/v1/hpg/games
10+
def index
11+
expires_in 1.hour, public: true
12+
13+
games = current_website.realty_games.visible_on_landing.currently_available
14+
data = games.map { |g| Pwb::Hpg::GameSerializer.call(g) }
15+
16+
render json: { data: data, meta: { total: data.size } }
17+
end
18+
19+
# GET /api_public/v1/hpg/games/:slug
20+
def show
21+
render json: Pwb::Hpg::GameSummarySerializer.call(@game)
22+
end
23+
end
24+
end
25+
end
26+
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class LeaderboardsController < BaseController
7+
# GET /api_public/v1/hpg/leaderboards
8+
def index
9+
expires_in 1.minute, public: true
10+
11+
sessions = current_website.game_sessions.for_leaderboard
12+
sessions = filter_by_game(sessions)
13+
sessions = filter_by_period(sessions)
14+
15+
limit = [(params[:limit] || 20).to_i, 100].min
16+
data = sessions.limit(limit).includes(:realty_game).map do |s|
17+
{
18+
rank: nil, # computed below
19+
guest_name: s.guest_name,
20+
total_score: s.total_score,
21+
estimates_count: s.estimates_count,
22+
game_slug: s.realty_game.slug,
23+
game_title: s.realty_game.title,
24+
created_at: s.created_at.iso8601
25+
}
26+
end
27+
28+
data.each_with_index { |entry, i| entry[:rank] = i + 1 }
29+
30+
render json: {
31+
data: data,
32+
meta: {
33+
total: sessions.count,
34+
period: params[:period] || 'all_time',
35+
game_slug: params[:game_slug]
36+
}
37+
}
38+
end
39+
40+
private
41+
42+
def filter_by_game(sessions)
43+
return sessions if params[:game_slug].blank?
44+
45+
sessions.joins(:realty_game)
46+
.where(pwb_realty_games: { slug: params[:game_slug] })
47+
end
48+
49+
def filter_by_period(sessions)
50+
case params[:period]
51+
when 'daily' then sessions.where('pwb_game_sessions.created_at >= ?', 1.day.ago)
52+
when 'weekly' then sessions.where('pwb_game_sessions.created_at >= ?', 1.week.ago)
53+
when 'monthly' then sessions.where('pwb_game_sessions.created_at >= ?', 1.month.ago)
54+
else sessions
55+
end
56+
end
57+
end
58+
end
59+
end
60+
end
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class ListingsController < BaseController
7+
# GET /api_public/v1/hpg/listings/:uuid
8+
def show
9+
expires_in 1.hour, public: true
10+
11+
asset = current_website.realty_assets.find(params[:uuid])
12+
photos = asset.prop_photos.limit(10)
13+
14+
render json: {
15+
uuid: asset.id,
16+
reference: asset.reference,
17+
title: asset.title,
18+
street_address: asset.street_address,
19+
city: asset.city,
20+
country: asset.country,
21+
postal_code: asset.postal_code,
22+
bedrooms: asset.count_bedrooms,
23+
bathrooms: asset.count_bathrooms,
24+
area_sqm: asset.constructed_area,
25+
latitude: asset.latitude,
26+
longitude: asset.longitude,
27+
prop_type: asset.prop_type_key,
28+
photos: photos.map { |p| { id: p.id, url: p.image_url, thumbnail_url: p.thumbnail_url } }
29+
}
30+
end
31+
end
32+
end
33+
end
34+
end
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# frozen_string_literal: true
2+
3+
module ApiPublic
4+
module V1
5+
module Hpg
6+
class ResultsController < BaseController
7+
before_action :find_game!
8+
9+
# GET /api_public/v1/hpg/games/:slug/results/:session_id
10+
def show
11+
expires_in 1.minute, public: true
12+
13+
session = @game.game_sessions.find(params[:session_id])
14+
render json: Pwb::Hpg::ResultBoardSerializer.call(session, @game)
15+
end
16+
end
17+
end
18+
end
19+
end

app/models/pwb/access_code.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
# == Schema Information
4+
#
5+
# Table name: pwb_access_codes
6+
# Database name: primary
7+
#
8+
# id :uuid not null, primary key
9+
# active :boolean default(TRUE), not null
10+
# code :string not null
11+
# expires_at :datetime
12+
# max_uses :integer
13+
# uses_count :integer default(0), not null
14+
# created_at :datetime not null
15+
# updated_at :datetime not null
16+
# website_id :bigint not null
17+
#
18+
# Indexes
19+
#
20+
# index_pwb_access_codes_on_website_id (website_id)
21+
# index_pwb_access_codes_on_website_id_and_code (website_id,code) UNIQUE
22+
#
23+
# Foreign Keys
24+
#
25+
# fk_rails_... (website_id => pwb_websites.id)
26+
#
27+
module Pwb
28+
class AccessCode < ApplicationRecord
29+
self.table_name = 'pwb_access_codes'
30+
31+
belongs_to :website
32+
33+
validates :code, presence: true, uniqueness: { scope: :website_id }
34+
35+
scope :active, -> { where(active: true) }
36+
scope :not_expired, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }
37+
scope :not_exhausted, -> { where('max_uses IS NULL OR uses_count < max_uses') }
38+
scope :valid, -> { active.not_expired.not_exhausted }
39+
40+
def valid_code?
41+
active? && !expired? && !exhausted?
42+
end
43+
44+
def expired?
45+
expires_at.present? && expires_at < Time.current
46+
end
47+
48+
def exhausted?
49+
max_uses.present? && uses_count >= max_uses
50+
end
51+
52+
def redeem!
53+
increment!(:uses_count)
54+
end
55+
end
56+
end

app/models/pwb/game_estimate.rb

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
# == Schema Information
4+
#
5+
# Table name: pwb_game_estimates
6+
# Database name: primary
7+
#
8+
# id :uuid not null, primary key
9+
# actual_price_cents :bigint not null
10+
# currency :string default("EUR"), not null
11+
# estimate_details :jsonb not null
12+
# estimated_price_cents :bigint not null
13+
# percentage_diff :decimal(8, 2)
14+
# property_index :integer
15+
# score :integer default(0), not null
16+
# created_at :datetime not null
17+
# updated_at :datetime not null
18+
# game_listing_id :uuid not null
19+
# game_session_id :uuid not null
20+
# website_id :bigint not null
21+
#
22+
# Indexes
23+
#
24+
# index_pwb_game_estimates_on_game_listing_id (game_listing_id)
25+
# index_pwb_game_estimates_on_game_session_id (game_session_id)
26+
# index_pwb_game_estimates_on_website_id (website_id)
27+
# index_pwb_game_estimates_unique_session_listing (game_session_id,game_listing_id) UNIQUE
28+
#
29+
# Foreign Keys
30+
#
31+
# fk_rails_... (game_listing_id => pwb_game_listings.id)
32+
# fk_rails_... (game_session_id => pwb_game_sessions.id)
33+
# fk_rails_... (website_id => pwb_websites.id)
34+
#
35+
module Pwb
36+
class GameEstimate < ApplicationRecord
37+
self.table_name = 'pwb_game_estimates'
38+
39+
belongs_to :game_session
40+
belongs_to :game_listing
41+
belongs_to :website
42+
43+
validates :estimated_price_cents, presence: true, numericality: { greater_than: 0 }
44+
validates :actual_price_cents, presence: true, numericality: { greater_than: 0 }
45+
validates :game_listing_id, uniqueness: { scope: :game_session_id,
46+
message: 'already has an estimate in this session' }
47+
48+
before_validation :calculate_score, on: :create
49+
after_create :update_session_score!
50+
after_create :increment_game_counter
51+
52+
private
53+
54+
def calculate_score
55+
return unless estimated_price_cents.present? && actual_price_cents.present?
56+
57+
calculator = Pwb::PriceGame::ScoreCalculator.new(
58+
guessed_cents: estimated_price_cents,
59+
actual_cents: actual_price_cents
60+
)
61+
self.score = calculator.score
62+
self.percentage_diff = calculator.percentage_diff
63+
self.estimate_details = calculator.result
64+
end
65+
66+
def update_session_score!
67+
game_session.recalculate_total_score!
68+
end
69+
70+
def increment_game_counter
71+
game_listing.realty_game.increment!(:estimates_count)
72+
end
73+
end
74+
end

0 commit comments

Comments
 (0)