diff --git a/db/migrations/062_geo_programs.rb b/db/migrations/062_geo_programs.rb new file mode 100644 index 000000000..39a11bd43 --- /dev/null +++ b/db/migrations/062_geo_programs.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + create_table(:map_locations) do + primary_key :id + numeric :lat, null: false + numeric :lng, null: false + unique [:lat, :lng] + end + create_join_table( + {map_location_id: :map_locations, offering_id: :commerce_offerings}, + name: :map_locations_commerce_offerings, + ) + end + + down do + drop_table(:map_locations) + drop_table(:map_locations_commerce_offerings) + end +end diff --git a/lib/suma/api/entities.rb b/lib/suma/api/entities.rb index 4fb6de0f3..d6233811d 100644 --- a/lib/suma/api/entities.rb +++ b/lib/suma/api/entities.rb @@ -33,6 +33,11 @@ class ImageEntity < BaseEntity expose :url, &self.delegate_to(:uploaded_file, :absolute_url) end + class MapLocationEntity < BaseEntity + expose :lat + expose :lng + end + class PaymentInstrumentEntity < BaseEntity expose :id expose :created_at diff --git a/lib/suma/api/me.rb b/lib/suma/api/me.rb index 82686b73f..447745c7d 100644 --- a/lib/suma/api/me.rb +++ b/lib/suma/api/me.rb @@ -3,7 +3,6 @@ require "grape" require "suma/api" -require "suma/member/dashboard" class Suma::API::Me < Suma::API::V1 include Suma::API::Entities @@ -46,11 +45,6 @@ class Suma::API::Me < Suma::API::V1 present member, with: CurrentMemberEntity, env: end - get :dashboard do - d = Suma::Member::Dashboard.new(current_member, at: current_time) - present d, with: DashboardEntity - end - params do requires :language, values: ["en", "es"] end @@ -63,9 +57,4 @@ class Suma::API::Me < Suma::API::V1 present member, with: CurrentMemberEntity, env: end end - - class DashboardEntity < BaseEntity - expose :cash_balance, with: Suma::API::Entities::MoneyEntity - expose :program_enrollments, as: :programs, with: Suma::API::Entities::ProgramEnrollmentEntity - end end diff --git a/lib/suma/api/mobility.rb b/lib/suma/api/mobility.rb index 2b6f4bc30..8a530dd2d 100644 --- a/lib/suma/api/mobility.rb +++ b/lib/suma/api/mobility.rb @@ -68,12 +68,16 @@ class Suma::API::Mobility < Suma::API::V1 requires :ne, type: Array[BigDecimal], coerce_with: DecimalLocation end get :map_features do - current_member + me = current_member min_lat, min_lng = params[:sw] max_lat, max_lng = params[:ne] - ds = Suma::Mobility::RestrictedArea.intersecting(ne: [max_lat, max_lng], sw: [min_lat, min_lng]) - ds = ds.order(:id) - result = {restrictions: ds.all} + restrictions_ds = Suma::Mobility::RestrictedArea.intersecting(ne: [max_lat, max_lng], sw: [min_lat, min_lng]) + offerings_ds = Suma::Commerce::Offering. + for_map_display_to(me, as_of: current_time, min_lat:, min_lng:, max_lat:, max_lng:) + result = { + restrictions: restrictions_ds.order(:id).all, + commerce_offerings: offerings_ds.all, + } present result, with: MobilityMapFeaturesEntity end @@ -174,8 +178,18 @@ class MobilityMapRestrictionEntity < BaseEntity expose :bounds_numeric, as: :bounds end + class MobilityMapProgramEntity < BaseEntity + expose :id + expose_translated :description + expose :period_end, as: :closes_at + expose :image, with: Suma::API::Entities::ImageEntity, &self.delegate_to(:images?, :first) + expose :rel_app_link, as: :app_link + expose :map_locations, with: Suma::API::Entities::MapLocationEntity + end + class MobilityMapFeaturesEntity < BaseEntity expose :restrictions, with: MobilityMapRestrictionEntity + expose :programs, with: MobilityMapProgramEntity end class MobilityVehicleEntity < BaseEntity diff --git a/lib/suma/commerce/offering.rb b/lib/suma/commerce/offering.rb index 9ed12959b..10252b28d 100644 --- a/lib/suma/commerce/offering.rb +++ b/lib/suma/commerce/offering.rb @@ -19,15 +19,14 @@ class Suma::Commerce::Offering < Suma::Postgres::Model(:commerce_offerings) plugin :translated_text, :fulfillment_confirmation, Suma::TranslatedText plugin :translated_text, :fulfillment_instructions, Suma::TranslatedText - many_to_many :programs, - class: "Suma::Program", - join_table: :programs_commerce_offerings, - left_key: :offering_id, - right_key: :program_id - one_to_many :fulfillment_options, class: "Suma::Commerce::OfferingFulfillmentOption" one_to_many :offering_products, class: "Suma::Commerce::OfferingProduct" one_to_many :carts, class: "Suma::Commerce::Cart" + many_to_many :map_locations, + class: "Suma::MapLocation", + join_table: :map_locations_commerce_offerings, + left_key: :offering_id, + right_key: :map_location_id many_to_many :programs, class: "Suma::Program", @@ -156,6 +155,12 @@ def total_ordered_items = total_ordered_items_by_member.values.sum def available_at(t) return self.where(Sequel.pg_range(:period).contains(Sequel.cast(t, :timestamptz))) end + + def for_map_display_to(member, as_of:, min_lat:, min_lng:, max_lat:, max_lng:) + ds = self.available_at(as_of).eligible_to(member, as_of:) + map_locs_ds = Suma::MapLocation.search(min_lat:, min_lng:, max_lat:, max_lng:) + return ds.where(map_locations: map_locs_ds) + end end # @!attribute max_ordered_items_cumulative diff --git a/lib/suma/fixtures/map_locations.rb b/lib/suma/fixtures/map_locations.rb new file mode 100644 index 000000000..b7ec84312 --- /dev/null +++ b/lib/suma/fixtures/map_locations.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "suma/fixtures" +require "suma/map_location" + +module Suma::Fixtures::MapLocations + extend Suma::Fixtures + + fixtured_class Suma::MapLocation + + base :map_location do + self.lat ||= Faker::Number.between(from: -90.0, to: 90.0) + self.lng ||= Faker::Number.between(from: -180.0, to: 180.0) + end + + decorator :at do |lat, lng| + self.lat = lat + self.lng = lng + end +end diff --git a/lib/suma/fixtures/offerings.rb b/lib/suma/fixtures/offerings.rb index 9fe001067..8911169dc 100644 --- a/lib/suma/fixtures/offerings.rb +++ b/lib/suma/fixtures/offerings.rb @@ -43,4 +43,8 @@ module Suma::Fixtures::Offerings decorator :with_programs, presave: true do |*programs| programs.each { |p| self.add_program(p) } end + + decorator :located, presave: true do |lat, lng| + self.add_map_location(lat:, lng:) + end end diff --git a/lib/suma/map_location.rb b/lib/suma/map_location.rb new file mode 100644 index 000000000..c0578002b --- /dev/null +++ b/lib/suma/map_location.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "suma/postgres/model" + +class Suma::MapLocation < Suma::Postgres::Model(:map_locations) + many_to_many :commerce_offerings, + class: "Suma::Commerce::Offering", + join_table: :map_locations_commerce_offerings, + right_key: :offering_id + + class << self + def search_expr(min_lat:, min_lng:, max_lat:, max_lng:, lat_col: :lat, lng_col: :lng) + return (Sequel[lat_col] >= min_lat) & + (Sequel[lat_col] <= max_lat) & + (Sequel[lng_col] >= min_lng) & + (Sequel[lng_col] <= max_lng) + end + end + + dataset_module do + def search(min_lat:, min_lng:, max_lat:, max_lng:) + return self.where(Suma::MapLocation.search_expr(min_lat:, min_lng:, max_lat:, max_lng:)) + end + end +end diff --git a/lib/suma/member/dashboard.rb b/lib/suma/member/dashboard.rb deleted file mode 100644 index ad3de65dc..000000000 --- a/lib/suma/member/dashboard.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "suma/member" -require "suma/payment/ledgers_view" - -class Suma::Member::Dashboard - def initialize(member, at:) - @member = member - @at = at - end - - def cash_balance - return @member.payment_account!.cash_ledger!.balance - end - - def program_enrollments - return @program_enrollments ||= @member.combined_program_enrollments_dataset.active(as_of: @at). - all.sort_by { |pe| pe.program.ordinal } - end -end diff --git a/lib/suma/postgres.rb b/lib/suma/postgres.rb index 17cd39a99..4ef9e9fd5 100644 --- a/lib/suma/postgres.rb +++ b/lib/suma/postgres.rb @@ -68,6 +68,7 @@ def self.check_transaction(db, error_msg) "suma/commerce/product_inventory", "suma/external_credential", "suma/image", + "suma/map_location", "suma/member", "suma/member/activity", "suma/member/referral", diff --git a/spec/suma/api/me_spec.rb b/spec/suma/api/me_spec.rb index 6eee8970b..5cdc01321 100644 --- a/spec/suma/api/me_spec.rb +++ b/spec/suma/api/me_spec.rb @@ -136,21 +136,4 @@ expect(member.refresh.message_preferences).to have_attributes(preferred_language: "es") end end - - describe "GET /v1/me/dashboard" do - it "returns the dashboard" do - led = Suma::Payment.ensure_cash_ledger(Suma::Fixtures.payment_account.create(member:)) - Suma::Fixtures.book_transaction.to(led).create(amount: money("$10")) - Suma::Fixtures.program_enrollment.create(member:) - - get "/v1/me/dashboard" - - expect(last_response).to have_status(200) - expect(last_response).to have_json_body. - that_includes( - cash_balance: include(cents: 1000), - programs: have_length(1), - ) - end - end end diff --git a/spec/suma/api/mobility_spec.rb b/spec/suma/api/mobility_spec.rb index 20e4c7be8..ce0ca6317 100644 --- a/spec/suma/api/mobility_spec.rb +++ b/spec/suma/api/mobility_spec.rb @@ -231,10 +231,10 @@ def get_same_location_vehicles(amount) describe "GET /v1/mobility/map_features" do it "returns the location of restricted areas within the given bounds" do - ra1 = Suma::Fixtures.mobility_restricted_area. + inbounds = Suma::Fixtures.mobility_restricted_area. latlng_bounds(sw: [20, 120], ne: [50, 150]). create(restriction: "do-not-park-or-ride") - ra2 = Suma::Fixtures.mobility_restricted_area. + outofbounds = Suma::Fixtures.mobility_restricted_area. latlng_bounds(sw: [30, 130], ne: [50, 150]). create(restriction: "do-not-park") @@ -266,6 +266,18 @@ def get_same_location_vehicles(amount) ) end + it "returns the offerings within the given bounds" do + inbounds = Suma::Fixtures.offering.located(20, 120).create + outbounds = Suma::Fixtures.offering.located(20, 130).create + not_located = Suma::Fixtures.offering.create + + get "/v1/mobility/map_features", sw: [15, 110], ne: [25, 125] + + expect(last_response).to have_status(200) + expect(last_response).to have_json_body. + that_includes(commerce_offerings: have_same_ids_as(inbounds)) + end + it "401s if not logged in" do logout get "/v1/mobility/map_features", sw: [15, 110], ne: [25, 125] diff --git a/spec/suma/commerce/offering_spec.rb b/spec/suma/commerce/offering_spec.rb index 0d76b68b9..2b442f8e3 100644 --- a/spec/suma/commerce/offering_spec.rb +++ b/spec/suma/commerce/offering_spec.rb @@ -110,6 +110,28 @@ expect(with_program).to be_eligible_to(member_in_program, as_of:) expect(with_program).to_not be_eligible_to(member_unapproved, as_of:) end + + it "can find offerings for display on the map" do + as_of = Time.now + program = Suma::Fixtures.program.create + member = Suma::Fixtures.member.create + Suma::Fixtures.program_enrollment(member:, program:).create + + in_loc = Suma::Fixtures.map_location.at(20, 120).create + out_loc = Suma::Fixtures.map_location.at(50, 120).create + + other_program = Suma::Fixtures.offering.with_programs(Suma::Fixtures.program.create).create + other_program.add_map_location(in_loc) + in_program = Suma::Fixtures.offering.with_programs(program).create + in_program.add_map_location(in_loc) + bad_loc_program = Suma::Fixtures.offering.with_programs(program).create + bad_loc_program.add_map_location(out_loc) + unavailable = Suma::Fixtures.offering.with_programs(program).closed.create + unavailable.add_map_location(in_loc) + + ds = described_class.for_map_display_to(member, as_of:, min_lat: 15, max_lat: 25, min_lng: 110, max_lng: 130) + expect(ds.all).to have_same_ids_as(in_program) + end end describe "#begin_order_fulfillment" do diff --git a/spec/suma/member/dashboard_spec.rb b/spec/suma/member/dashboard_spec.rb deleted file mode 100644 index 08c082b1b..000000000 --- a/spec/suma/member/dashboard_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "suma/member/dashboard" - -RSpec.describe Suma::Member::Dashboard, :db do - let(:member) { Suma::Fixtures.member.with_cash_ledger.create } - let(:now) { Time.now } - - it "can represent a blank/empty member" do - d = described_class.new(member, at: now) - expect(d).to have_attributes(cash_balance: money("$0"), program_enrollments: []) - end - - it "includes enrolled programs" do - pe1 = Suma::Fixtures.program_enrollment.create(member:) - pe2 = Suma::Fixtures.program_enrollment.create(member:) - expect(described_class.new(member, at: now)).to have_attributes( - program_enrollments: have_same_ids_as(pe1, pe2), - ) - end - - it "sorts enrollments by program ordinal" do - p3 = Suma::Fixtures.program.create(ordinal: 3) - p1 = Suma::Fixtures.program.create(ordinal: 1) - p2 = Suma::Fixtures.program.create(ordinal: 2) - pe3 = Suma::Fixtures.program_enrollment.create(member:, program: p3) - pe1 = Suma::Fixtures.program_enrollment.create(member:, program: p1) - pe2 = Suma::Fixtures.program_enrollment.create(member:, program: p2) - enrollments = described_class.new(member, at: now).program_enrollments - expect(enrollments.first).to have_attributes(program: p1) - expect(enrollments.second).to have_attributes(program: p2) - expect(enrollments.last).to have_attributes(program: p3) - end -end