Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Block Request Group Handling Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Move group block handling from Api::Partner::V1::InventoriesController#block to Api::Partner::V1::BlockRequestsController#create, revert the inventory controller to main, and make group block operations atomic.

Architecture: Controller-level dispatch decides between single-restaurant (BlockRequest::SubmitService) and group (BlockRequest::GroupSubmitService) paths. GroupSubmitService wraps flagged-request creation and direct inventory blocks in one transaction, raising a custom error on any failure so the whole group rolls back.

Tech Stack: Ruby on Rails, RSpec, ActiveRecord, Trailblazer InventoryUpdater


File Structure

FileResponsibility
app/controllers/api/partner/v1/block_requests_controller.rbDispatches create to single or group handler; renders JSON; runs ChangeTracker for direct blocks in group mode.
app/controllers/api/partner/v1/inventories_controller.rbReverted to main branch: single-restaurant block only, no group logic.
app/services/block_request/group_submit_service.rbPartitions restaurants, creates pending requests for flagged restaurants, direct-blocks the rest, all inside a transaction.
app/services/block_request/group_submit_error.rbCustom error raised inside the transaction to trigger rollback while preserving the failure message.
spec/requests/api/partner/v1/block_requests_spec.rbAdds request specs for group block scenarios.
spec/requests/api/partner/v1/inventories_spec.rbRemoves group-block specs; keeps single-restaurant specs.
spec/services/block_request/group_submit_service_spec.rbAdds atomic rollback spec.

Task 1: Revert InventoriesController#block to main

Files:

  • Modify: app/controllers/api/partner/v1/inventories_controller.rb:95-258

Context: The current branch added handle_group_block and changed how restaurant_id is parsed. We will restore the main branch version of the block action and remove the private handle_group_block helper.

  • Step 1: Replace the block action and helpers with the main branch version

Replace lines 95–258 with the following (matches main):

    def block
      valid_params = params.require(:data).require(:attributes).permit(:start_date, :end_date,
                                                                       :start_time, :end_time, :reason, :restaurant_id,
                                                                       :service_type)
      restaurant_id = valid_params[:restaurant_id].presence || default_restaurant.id
      restaurant_ids = restaurant_id == 'all' ? restaurants.pluck(:id) : [restaurant_id]

      target_restaurant = restaurant_id == 'all' ? default_restaurant : Restaurant.find(restaurant_id)

      if block_request_required?(target_restaurant, valid_params[:start_date])
        handle_flagged_block_request(target_restaurant, valid_params)
      else
        @change_tracker.track_block_inventory(valid_params, restaurant_id)
        service = PartnerService::Inventories::BlockService.new(params, restaurant_ids).call
        if service.success?
          @change_tracker.notify_managers default_restaurant || Restaurant.find(restaurant_id)
          render json: { success: true, message: service.message }
        else
          render json: { success: false, message: service.message }, status: :unprocessable_entity
        end
      end
    end

Keep block_request_required? and handle_flagged_block_request as they are on main. Remove handle_group_block entirely.

  • Step 2: Verify the diff against main for the controller

Run:

git diff main -- app/controllers/api/partner/v1/inventories_controller.rb

Expected: only unrelated differences remain (if any); the block action and helpers match main.

  • Step 3: Commit
git add app/controllers/api/partner/v1/inventories_controller.rb
git commit -m "revert: restore InventoriesController#block to main branch behavior

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2: Add custom rollback error for group submissions

Files:

  • Create: app/services/block_request/group_submit_error.rb

  • Step 1: Create the error class

# typed: ignore
# frozen_string_literal: true

module BlockRequest
  # Raised inside GroupSubmitService when any part of a group block fails.
  # The message is surfaced to the caller; raising triggers an ActiveRecord rollback.
  class GroupSubmitError < StandardError
  end
end
  • Step 2: Commit
git add app/services/block_request/group_submit_error.rb
git commit -m "feat: add GroupSubmitError for atomic group block rollback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3: Make GroupSubmitService atomic

Files:

  • Modify: app/services/block_request/group_submit_service.rb:23-70

  • Step 1: Wrap the entire operation in a transaction and raise on failure

Replace the call method and submit_flagged_requests with:

    # Processes group block by creating pending requests for flagged restaurants
    # and directly blocking non-flagged ones. The whole operation is atomic.
    #
    # @return [ServiceResult] success with block_requests and direct_block data,
    #                         or failure with error messages
    def call
      ActiveRecord::Base.transaction do
        flagged, direct_block_ids = partition_restaurants

        block_requests = submit_flagged_requests(flagged)
        direct_result = perform_direct_blocks(direct_block_ids)

        build_success_result(block_requests, direct_block_ids, direct_result)
      end
    rescue BlockRequest::GroupSubmitError => e
      ServiceResult.failure(errors: [e.message], message: e.message)
    end

Replace submit_flagged_requests with:

    # Creates pending block requests for each flagged restaurant.
    # Raises GroupSubmitError on any failure so the outer transaction rolls back.
    def submit_flagged_requests(flagged_restaurants)
      requests = []

      flagged_restaurants.each do |restaurant|
        result = BlockRequest::SubmitService.new(
          restaurant: restaurant,
          staff: @staff,
          params: @params,
        ).call

        if result.success?
          requests << result.data
        else
          raise BlockRequest::GroupSubmitError, "#{restaurant.name}: #{result.message}"
        end
      end

      requests
    end

perform_direct_blocks already returns a ServiceResult on failure. Update it to raise the same error:

    # Directly blocks inventory for non-flagged restaurants.
    def perform_direct_blocks(restaurant_ids)
      return nil if restaurant_ids.empty?

      errors = []
      success_messages = []

      restaurant_ids.each do |restaurant_id|
        operation = InventoryUpdater.call(
          {
            restaurant_id: restaurant_id,
            start_date: @params[:start_date].to_date,
            end_date: adjusted_end_date,
            start_time: @params[:start_time],
            end_time: @params[:end_time],
            quantity_available: 0,
            reason: @params[:reason],
          },
          'settings' => { notify_staff: true },
        )

        if operation.success?
          msg = operation['result.success_message'].presence || 'success'
          success_messages << msg
        else
          msg = operation['result.fail_message'].presence || 'Unknown error'
          errors << msg
        end
      end

      if errors.any?
        raise BlockRequest::GroupSubmitError, errors.uniq.to_sentence
      else
        success_messages.uniq.to_sentence
      end
    end
  • Step 2: Run the existing service specs

Run:

bundle exec rspec spec/services/block_request/group_submit_service_spec.rb

Expected: existing specs still pass (they assert failure on error; raising still produces a failure result).

  • Step 3: Commit
git add app/services/block_request/group_submit_service.rb
git commit -m "feat: make GroupSubmitService atomic with all-or-nothing rollback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 4: Extend BlockRequestsController#create for group blocks

Files:

  • Modify: app/controllers/api/partner/v1/block_requests_controller.rb

  • Step 1: Rewrite create and add private helpers

Replace create with:

    def create
      attributes = block_request_params
      restaurant_id = attributes[:restaurant_id]

      if restaurant_id.blank? || restaurant_id == 'all'
        handle_group_block(attributes)
      else
        handle_single_block(attributes)
      end
    end
    span_method :create

Add these private methods before block_request_params:

    def handle_single_block(attributes)
      restaurant = if attributes[:restaurant_id].present?
                     current_staff.restaurants.find(attributes[:restaurant_id])
                   else
                     current_staff.default_restaurant
                   end

      result = BlockRequest::SubmitService.new(
        restaurant: restaurant,
        staff: current_staff,
        params: attributes,
      ).call

      if result.success?
        render json: ::Api::Partner::BlockAllotmentRequestSerializer.new(
          result.data,
          set_options,
        ).as_json, status: :created
      else
        error(result.message, :unprocessable_entity)
      end
    end

    def handle_group_block(attributes)
      result = BlockRequest::GroupSubmitService.new(
        restaurants: current_staff.restaurants.to_a,
        staff: current_staff,
        params: attributes,
      ).call

      if result.success?
        track_and_notify_group_block(result.data, attributes)
        render_group_block_response(result.data)
      else
        error(result.message, :unprocessable_entity)
      end
    end

    def track_and_notify_group_block(data, attributes)
      return if data[:direct_block_ids].empty?

      change_tracker = PartnerService::ChangeTracker.new(current_staff)
      change_tracker.track_block_inventory(attributes, 'all')
      change_tracker.notify_managers(current_staff.default_restaurant)
    end

    def render_group_block_response(data)
      response = ::Api::Partner::BlockAllotmentRequestSerializer.new(
        data[:block_requests],
        set_options,
      ).as_json
      response[:direct_block_ids] = data[:direct_block_ids]

      render json: response, status: :created
    end
  • Step 2: Run request specs for block_requests

Run:

bundle exec rspec spec/requests/api/partner/v1/block_requests_spec.rb

Expected: existing specs pass; group-block specs added later will fail until Task 6.

  • Step 3: Commit
git add app/controllers/api/partner/v1/block_requests_controller.rb
git commit -m "feat: handle single and group block in BlockRequestsController

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 5: Remove group-block specs from inventories_spec.rb

Files:

  • Modify: spec/requests/api/partner/v1/inventories_spec.rb:53-145

  • Step 1: Delete group-block contexts and keep single-restaurant tests

Remove the entire context 'with restaurant_id == all (group block) and multiple flagged restaurants' block (lines 53–145). Keep only the context 'with a single flagged restaurant' and context 'with a non-flagged restaurant' blocks.

  • Step 2: Run the remaining inventory block specs

Run:

bundle exec rspec spec/requests/api/partner/v1/inventories_spec.rb

Expected: all remaining specs pass.

  • Step 3: Commit
git add spec/requests/api/partner/v1/inventories_spec.rb
git commit -m "test: remove group-block specs from inventories request spec

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 6: Add group-block request specs

Files:

  • Modify: spec/requests/api/partner/v1/block_requests_spec.rb

  • Step 1: Add group-block contexts after the existing POST /api/partner/v1/block_requests describe block

Append the following inside describe 'POST /api/partner/v1/block_requests' (after line 360, before describe 'PATCH ...'):

    context 'with restaurant_id == all (group block)' do
      let(:other_restaurant) do
        create(:valid_restaurant, city: city, country: country).tap { |r| r.update_column(:flag, true) }
      end
      let(:non_flagged_restaurant) do
        create(:valid_restaurant, city: city, country: country, flag: false)
      end
      let(:staff) do
        create(:staff).tap do |s|
          create(:staff_role, staff: s, restaurant: restaurant)
          create(:staff_role, staff: s, restaurant: other_restaurant)
        end
      end
      let(:group_body) do
        {
          data: {
            attributes: {
              start_date: (Date.current + 5.days).to_s,
              end_date: (Date.current + 7.days).to_s,
              start_time: '10:00',
              end_time: '22:00',
              reason: 'Restaurant renovation',
              restaurant_id: 'all',
            },
          },
        }
      end

      before do
        success_op = double('TrailblazerOperation')
        allow(success_op).to receive(:success?).and_return(true)
        allow(success_op).to receive(:[]).with('result.success_message').and_return('Inventory blocked')
        allow(InventoryUpdater).to receive(:call).and_return(success_op)
      end

      it 'creates pending block requests for all flagged restaurants' do
        expect do
          post '/api/partner/v1/block_requests',
               params: group_body,
               headers: auth_headers
        end.to change(BlockAllotmentRequest, :count).by(2)

        expect(response).to have_http_status(:created)
        json = JSON.parse(response.body, symbolize_names: true)
        expect(json[:data].length).to eq(2)
        expect(BlockAllotmentRequest.pluck(:restaurant_id)).to contain_exactly(
          restaurant.id, other_restaurant.id
        )
      end

      context 'when one restaurant is not flagged' do
        let(:staff) do
          create(:staff).tap do |s|
            create(:staff_role, staff: s, restaurant: restaurant)
            create(:staff_role, staff: s, restaurant: non_flagged_restaurant)
          end
        end

        it 'creates pending request for flagged and direct blocks non-flagged' do
          expect do
            post '/api/partner/v1/block_requests',
                 params: group_body,
                 headers: auth_headers
          end.to change(BlockAllotmentRequest, :count).by(1)

          expect(response).to have_http_status(:created)
          json = JSON.parse(response.body, symbolize_names: true)
          expect(json[:data].first[:attributes][:status]).to eq('pending')
          expect(json).to have_key(:direct_block_ids)
          expect(json[:direct_block_ids]).to include(non_flagged_restaurant.id)
        end
      end

      context 'when all restaurants are non-flagged' do
        let(:staff) do
          create(:staff).tap do |s|
            create(:staff_role, staff: s, restaurant: non_flagged_restaurant)
          end
        end
        let(:group_body) do
          {
            data: {
              attributes: {
                start_date: (Date.current + 5.days).to_s,
                end_date: (Date.current + 7.days).to_s,
                start_time: '10:00',
                end_time: '22:00',
                reason: 'Restaurant renovation',
                restaurant_id: 'all',
              },
            },
          }
        end

        it 'directly blocks all restaurants without pending requests' do
          expect do
            post '/api/partner/v1/block_requests',
                 params: group_body,
                 headers: auth_headers
          end.not_to change(BlockAllotmentRequest, :count)

          expect(response).to have_http_status(:created)
          json = JSON.parse(response.body, symbolize_names: true)
          expect(json[:data]).to be_empty
          expect(json[:direct_block_ids]).to contain_exactly(non_flagged_restaurant.id)
        end
      end

      context 'when one flagged submission fails' do
        let(:staff) do
          create(:staff).tap do |s|
            create(:staff_role, staff: s, restaurant: restaurant)
            create(:staff_role, staff: s, restaurant: other_restaurant)
          end
        end

        before do
          call_count = 0
          allow(BlockRequest::SubmitService).to receive(:new) do |**kwargs|
            call_count += 1
            submit_double = instance_double(BlockRequest::SubmitService)
            if call_count == 1
              allow(submit_double).to receive(:call).and_return(
                ServiceResult.success(data: create(:block_allotment_request,
                                                   restaurant: kwargs[:restaurant],
                                                   requested_by_staff: staff)),
              )
            else
              allow(submit_double).to receive(:call).and_return(
                ServiceResult.failure(errors: ['Duplicate'], message: 'already exists'),
              )
            end
            submit_double
          end
        end

        it 'rolls back all changes and returns unprocessable entity' do
          expect do
            post '/api/partner/v1/block_requests',
                 params: group_body,
                 headers: auth_headers
          end.not_to change(BlockAllotmentRequest, :count)

          expect(response).to have_http_status(:unprocessable_entity)
        end
      end
    end

    context 'with blank restaurant_id (group block by default)' do
      let(:other_restaurant) do
        create(:valid_restaurant, city: city, country: country).tap { |r| r.update_column(:flag, true) }
      end
      let(:staff) do
        create(:staff).tap do |s|
          create(:staff_role, staff: s, restaurant: restaurant)
          create(:staff_role, staff: s, restaurant: other_restaurant)
        end
      end
      let(:group_body) do
        {
          data: {
            attributes: {
              start_date: (Date.current + 5.days).to_s,
              end_date: (Date.current + 7.days).to_s,
              start_time: '10:00',
              end_time: '22:00',
              reason: 'Restaurant renovation',
            },
          },
        }
      end

      before do
        success_op = double('TrailblazerOperation')
        allow(success_op).to receive(:success?).and_return(true)
        allow(success_op).to receive(:[]).with('result.success_message').and_return('Inventory blocked')
        allow(InventoryUpdater).to receive(:call).and_return(success_op)
      end

      it 'creates pending block requests for all flagged restaurants' do
        expect do
          post '/api/partner/v1/block_requests',
               params: group_body,
               headers: auth_headers
        end.to change(BlockAllotmentRequest, :count).by(2)

        expect(response).to have_http_status(:created)
      end
    end
  • Step 2: Run the block_requests request specs

Run:

bundle exec rspec spec/requests/api/partner/v1/block_requests_spec.rb

Expected: all specs pass.

  • Step 3: Commit
git add spec/requests/api/partner/v1/block_requests_spec.rb
git commit -m "test: add group block request specs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 7: Add atomic rollback service spec

Files:

  • Modify: spec/services/block_request/group_submit_service_spec.rb

  • Step 1: Add a rollback assertion to the existing failure context

In context 'when one flagged submission fails' (around line 141), change the example to:

      it 'returns failure and rolls back all created requests' do
        expect { service.call }.not_to change(BlockAllotmentRequest, :count)

        result = service.call
        expect(result).not_to be_success
        expect(result.message).to include('already exists')
      end

Add a new context for direct-block rollback:

    context 'when direct block fails after a pending request was created' do
      let(:restaurants) { [flagged_restaurant, non_flagged_restaurant] }

      before do
        failure_op = double('TrailblazerOperation')
        allow(failure_op).to receive(:success?).and_return(false)
        allow(failure_op).to receive(:[]).with('result.fail_message').and_return('Inventory update failed')
        allow(InventoryUpdater).to receive(:call).and_return(failure_op)
      end

      it 'rolls back the pending request' do
        expect { service.call }.not_to change(BlockAllotmentRequest, :count)

        result = service.call
        expect(result).not_to be_success
        expect(result.message).to include('Inventory update failed')
      end
    end
  • Step 2: Run the service specs

Run:

bundle exec rspec spec/services/block_request/group_submit_service_spec.rb

Expected: all specs pass.

  • Step 3: Commit
git add spec/services/block_request/group_submit_service_spec.rb
git commit -m "test: assert atomic rollback in GroupSubmitService

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 8: Final verification

  • Step 1: Run the full related test suite

Run:

bundle exec rspec spec/requests/api/partner/v1/block_requests_spec.rb spec/requests/api/partner/v1/inventories_spec.rb spec/services/block_request/group_submit_service_spec.rb spec/services/block_request/submit_service_spec.rb

Expected: all specs pass.

  • Step 2: Check diff for correctness

Run:

git diff --stat

Expected changes:

  • app/controllers/api/partner/v1/block_requests_controller.rb

  • app/controllers/api/partner/v1/inventories_controller.rb

  • app/services/block_request/group_submit_error.rb (new)

  • app/services/block_request/group_submit_service.rb

  • spec/requests/api/partner/v1/block_requests_spec.rb

  • spec/requests/api/partner/v1/inventories_spec.rb

  • spec/services/block_request/group_submit_service_spec.rb

  • Step 3: Final commit (if not already committed)

If all tests pass and changes are committed per-task, no additional commit is needed.


Spec Coverage Checklist

Spec RequirementImplementing Task
Revert InventoriesController#block to mainTask 1
BlockRequestsController#create dispatches single vs groupTask 4
Group block handles restaurant_id == 'all'Task 4, 6
Group block handles blank restaurant_idTask 4, 6
Group block is atomic (all-or-nothing)Task 2, 3, 7
ChangeTracker moved to BlockRequestsControllerTask 4
Specs moved from inventories_spec to block_requests_specTask 5, 6

Notes

  • current_staff.restaurants is used for group block instead of the restaurants helper because the helper treats 'all' as a literal restaurant ID and would raise NotAuthorized.
  • BlockRequest::SubmitService already validates flagged status, date range, reason, and duplicates. GroupSubmitService relies on those validations and rolls back the whole group if any single submission fails.
  • ChangeTracker notifications are only sent when there are direct blocks (direct_block_ids.any?), matching the original behavior in InventoriesController.