From 0d88c568b9fc10dace806f24297576a9bd629edd Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Fri, 5 Jun 2026 11:31:13 -0500 Subject: [PATCH 1/3] Distinguish expired holds from real checkouts on the appointment page The appointment checkout panel rendered "Item checked-out" for any hold that wasn't active?, but Hold#active? is false for both holds that were genuinely picked up and holds that simply timed out. A hold that expired without pickup looked identical to a completed checkout, which is what made staff believe an item was on a member's account when it never was. Split the label into three honest states (picked up, expired without pickup, ended without checkout) and rename the loop variable from `loan` to `hold`, since it was a Hold the whole time and the misleading name was half the confusion. Fixes #2180 --- .../admin/appointments/_checkouts.html.erb | 41 +++++++++++-------- .../admin/appointments_controller_test.rb | 22 ++++++++++ 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/app/views/admin/appointments/_checkouts.html.erb b/app/views/admin/appointments/_checkouts.html.erb index ba501c73e..2232e4b19 100644 --- a/app/views/admin/appointments/_checkouts.html.erb +++ b/app/views/admin/appointments/_checkouts.html.erb @@ -7,12 +7,12 @@ <%#= button_to "Check-out All", admin_appointment_checkouts_path(@appointment, hold_ids: appointment_pickup_items.active.pluck(:id)), class: "btn btn-primary", data: { disable_with: "Checking-out...", turbo_confirm: "Are you sure you want to check-out all items?" }, disabled: !appointment_pickup_items.active.exists? || !member.borrow? %> - <% appointment_pickup_items.each do |loan| %> + <% appointment_pickup_items.each do |hold| %>
<%= tag.div class: "item-image" do %> - <% if loan.item.image.attached? %> - <%= image_tag item_image_url(loan.item.image, resize_to_limit: [160, 112]) %> + <% if hold.item.image.attached? %> + <%= image_tag item_image_url(hold.item.image, resize_to_limit: [160, 112]) %> <% else %>
<% end %> @@ -20,13 +20,13 @@
-

<%= loan.item.name %>

-

<%= link_to full_item_number(loan.item), admin_item_path(loan.item) %>

+

<%= hold.item.name %>

+

<%= link_to full_item_number(hold.item), admin_item_path(hold.item) %>

- <% if loan.item.accessories? && loan.active? %> + <% if hold.item.accessories? && hold.active? %>
    - <% loan.item.accessories.each do |accessory| %> + <% hold.item.accessories.each do |accessory| %>
  • <%= label_tag accessory, accessory %> <%= checkbox_tag accessory, data: {:action => "confirm-item-accessories#handleCheck", "confirm-item-accessories-target" => "accessory"} %> @@ -34,15 +34,15 @@ <% end %>
<% end %> - <%= button_to "Check-out", admin_appointment_checkouts_path(@appointment, hold_ids: [loan.id]), - id: "checkout-#{loan.id}", + <%= button_to "Check-out", admin_appointment_checkouts_path(@appointment, hold_ids: [hold.id]), + id: "checkout-#{hold.id}", class: "btn btn-primary btn-sm", data: {:disable_with => "Checking-out...", "confirm-item-accessories-target" => "button"}, - disabled: !loan.active? || !member.borrow? || loan.item.accessories? %> + disabled: !hold.active? || !member.borrow? || hold.item.accessories? %>
- <% if loan.active? %> + <% if hold.active? %> <%= button_to "Cancel Hold", - admin_appointment_hold_path(@appointment, loan), { + admin_appointment_hold_path(@appointment, hold), { params: {cancel_hold: true}, method: :delete, class: "btn btn-sm mt-2 float-right", @@ -52,7 +52,7 @@ } } %> <%= button_to "Remove Item", - admin_appointment_hold_path(@appointment, loan), { + admin_appointment_hold_path(@appointment, hold), { params: {cancel_hold: false}, method: :delete, class: "btn btn-sm mt-2 float-right", @@ -61,19 +61,24 @@ turbo_confirm: "Are you sure you want to remove this item from the appointment?" } } %> - <% end %> - <% if !loan.active? %> - Item checked-out + <% else %> + <% if hold.loan.present? %> + Item checked-out + <% elsif hold.expired? %> + Hold expired without pickup + <% else %> + Hold ended without checkout + <% end %> <% end %>
- <% if loan.item.checkout_notice? %> + <% if hold.item.checkout_notice? %>

Checkout Notice:
- <%= loan.item.checkout_notice %> + <%= hold.item.checkout_notice %>

diff --git a/test/controllers/admin/appointments_controller_test.rb b/test/controllers/admin/appointments_controller_test.rb index 87718d6af..db6560cb9 100644 --- a/test/controllers/admin/appointments_controller_test.rb +++ b/test/controllers/admin/appointments_controller_test.rb @@ -46,5 +46,27 @@ class AppointmentsControllerTest < ActionDispatch::IntegrationTest get admin_appointment_path(@appointment) end end + + test "labels a hold that timed out as expired, not checked-out" do + appointment = build(:appointment, member: @member) + appointment.holds << create(:hold, :expired, member: @member) + appointment.save! + + get admin_appointment_path(appointment) + + assert_select "em", text: "Hold expired without pickup" + assert_select "em", text: "Item checked-out", count: 0 + end + + test "labels a hold that was actually picked up as checked-out" do + appointment = build(:appointment, member: @member) + loan = create(:loan, member: @member) + appointment.holds << create(:hold, :ended, member: @member, loan: loan) + appointment.save! + + get admin_appointment_path(appointment) + + assert_select "em", text: "Item checked-out" + end end end From fdf1939fbfc5abe1de0ad49d03c7080ede87484d Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Fri, 5 Jun 2026 11:31:13 -0500 Subject: [PATCH 2/3] Keep pulled appointments' holds from expiring mid-window A hold's expires_at ticks on its own clock regardless of whether its item has been pulled for an appointment. If staff pull an item and the "Check-out" click gets missed, the hold can expire that same night, the item silently flips back to available, and the member drops out of line. When an appointment is pulled, push its attached holds' expiry out to a backstop (the default hold duration) so they can't die while the item sits off the shelf waiting to be checked out. We only ever extend, never shorten, and leave un-pulled appointments alone. Fixes #2181 --- app/models/appointment.rb | 26 +++++++++++++++++++++++++ test/models/appointment_test.rb | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/app/models/appointment.rb b/app/models/appointment.rb index 2513551cf..cbb83fc01 100644 --- a/app/models/appointment.rb +++ b/app/models/appointment.rb @@ -6,10 +6,17 @@ class Appointment < ApplicationRecord belongs_to :member + # Backstop window for holds whose items have been pulled but not yet checked + # out. Matches the default hold duration so a pulled item is held for the + # member roughly as long as a fresh hold would be. + HOLD_PULL_EXPIRY_BACKSTOP = Hold::DEFAULT_HOLD_DURATION.days + validate :ends_at_later_than_starts_at, :date_present validate :starts_before_holds_expire, if: :member_updating validate :item_present, unless: :staff_updating + after_update :protect_pulled_holds_from_expiring, if: :saved_change_to_pulled_at? + scope :upcoming, -> { where("starts_at > ?", Time.zone.now).order(:starts_at) } scope :today_or_later, -> { where("starts_at > ?", Time.zone.now.beginning_of_day).order(:starts_at) } scope :only_today, -> { where(starts_at: Time.zone.now.all_day) } @@ -56,6 +63,25 @@ def cancel_if_no_items! private + # When staff pull the items for an appointment, the member's holds should not + # keep ticking down on their own clock while the items sit off the shelf + # waiting to be checked out (issue #2181). Otherwise a missed "Check-out" + # click can silently expire a hold mid-appointment, flipping the item back to + # "available" and bouncing the member out of line. We push expiry out to a + # backstop, only ever extending (never shortening), and leave un-pulled + # appointments alone. + def protect_pulled_holds_from_expiring + return if pulled_at.nil? + + backstop = (pulled_at + HOLD_PULL_EXPIRY_BACKSTOP).end_of_day + holds.each do |hold| + next unless hold.started? && !hold.ended? + next if hold.expires_at && hold.expires_at >= backstop + + hold.update_column(:expires_at, backstop) + end + end + def no_items? holds.empty? && loans.empty? end diff --git a/test/models/appointment_test.rb b/test/models/appointment_test.rb index 3f61b6b2b..5ad7b9394 100644 --- a/test/models/appointment_test.rb +++ b/test/models/appointment_test.rb @@ -140,4 +140,38 @@ class AppointmentTest < ActiveSupport::TestCase refute appointment.dropoff_only? end + + test "pulling an appointment protects attached holds from expiring on their own clock" do + member = create(:member, :with_user) + hold = create(:hold, member: member, creator: member.user, started_at: Time.current, expires_at: 1.day.from_now) + appointment = create(:appointment, member: member, holds: [hold]) + + appointment.update!(pulled_at: Time.current, staff_updating: true) + + expected = (appointment.pulled_at + Appointment::HOLD_PULL_EXPIRY_BACKSTOP).end_of_day + assert_equal expected.to_i, hold.reload.expires_at.to_i + end + + test "pulling never shortens a hold that already expires after the backstop" do + member = create(:member, :with_user) + far_future = 30.days.from_now + hold = create(:hold, member: member, creator: member.user, started_at: Time.current, expires_at: far_future) + appointment = create(:appointment, member: member, holds: [hold]) + + appointment.update!(pulled_at: Time.current, staff_updating: true) + + assert_equal far_future.to_i, hold.reload.expires_at.to_i + end + + test "un-pulling an appointment leaves the already-extended hold expiry untouched" do + member = create(:member, :with_user) + hold = create(:hold, member: member, creator: member.user, started_at: Time.current, expires_at: 1.day.from_now) + appointment = create(:appointment, member: member, holds: [hold]) + appointment.update!(pulled_at: Time.current, staff_updating: true) + extended = hold.reload.expires_at + + appointment.update!(pulled_at: nil, staff_updating: true) + + assert_equal extended.to_i, hold.reload.expires_at.to_i + end end From 26fa4d2a8da0d85bf9beb2b41f07e472c77c3e07 Mon Sep 17 00:00:00 2001 From: Paul Hinze Date: Fri, 5 Jun 2026 12:10:11 -0500 Subject: [PATCH 3/3] Surface pulled-but-never-completed appointments to staff When staff pull the items for an appointment but never close it out, any held item that didn't get checked out goes invisible: the item is off the shelf, there's no loan behind it, and nothing flags the dangling state. Paired with the appointment page mislabeling a timed-out hold as "Item checked-out" (#2180), this is how items end up appearing to wander off member accounts. Add an "Unfinished Appointments" report (Reports menu) plus a banner on the Appointments page that links to it whenever any exist. Unfinished means: pulled, never marked complete, from the last 30 days, and still holding at least one item that was neither checked out nor explicitly ended. Each row resolves in place with a "Mark complete" button (reversible from the appointment page) and links out to the appointment, member, and items for the cases that need investigating. The 30-day window is deliberate. Prod has ~330 pulled-but-never-completed appointments going back over a year, but only ~50 in the last 30 days; the old ones are stale records whose items have long since circulated to other borrowers, so we don't nag about them. The pre-existing backlog is left as a separate cleanup decision. Fixes #2182 --- .../admin/appointments_controller.rb | 2 + .../unfinished_appointments_controller.rb | 22 ++++++++ app/models/appointment.rb | 30 ++++++++++ .../appointments/_unfinished_banner.html.erb | 9 +++ app/views/admin/appointments/index.html.erb | 2 + .../admin/appointments/index_orig.html.erb | 2 + .../unfinished_appointments/index.html.erb | 56 +++++++++++++++++++ app/views/layouts/admin.html.erb | 2 + config/routes.rb | 5 ++ .../admin/appointments_controller_test.rb | 9 +++ ...unfinished_appointments_controller_test.rb | 47 ++++++++++++++++ test/models/appointment_test.rb | 32 +++++++++++ 12 files changed, 218 insertions(+) create mode 100644 app/controllers/admin/reports/unfinished_appointments_controller.rb create mode 100644 app/views/admin/appointments/_unfinished_banner.html.erb create mode 100644 app/views/admin/reports/unfinished_appointments/index.html.erb create mode 100644 test/controllers/admin/reports/unfinished_appointments_controller_test.rb diff --git a/app/controllers/admin/appointments_controller.rb b/app/controllers/admin/appointments_controller.rb index d531e5c80..a5aa79291 100644 --- a/app/controllers/admin/appointments_controller.rb +++ b/app/controllers/admin/appointments_controller.rb @@ -10,6 +10,8 @@ def index .chronologically .includes(:member, loans: {item: :borrow_policy}, holds: {item: :borrow_policy}) + @unfinished_appointments_count = Appointment.unfinished.count + @pending_appointments = [] @pulled_appointments = [] @completed_appointments = [] diff --git a/app/controllers/admin/reports/unfinished_appointments_controller.rb b/app/controllers/admin/reports/unfinished_appointments_controller.rb new file mode 100644 index 000000000..983071b43 --- /dev/null +++ b/app/controllers/admin/reports/unfinished_appointments_controller.rb @@ -0,0 +1,22 @@ +module Admin + module Reports + class UnfinishedAppointmentsController < BaseController + def index + @appointments = Appointment.unfinished + .chronologically + .includes(:member, holds: :item) + end + + # Resolve a row straight from the worklist: mark the appointment complete + # so it drops off the list. Reversible from the appointment page if needed. + def complete + appointment = Appointment.find(params[:id]) + appointment.update!(completed_at: Time.current, staff_updating: true) + + redirect_to admin_reports_unfinished_appointments_path, + status: :see_other, + flash: {success: "Appointment marked complete."} + end + end + end +end diff --git a/app/models/appointment.rb b/app/models/appointment.rb index cbb83fc01..78e3ca165 100644 --- a/app/models/appointment.rb +++ b/app/models/appointment.rb @@ -23,6 +23,31 @@ class Appointment < ApplicationRecord scope :chronologically, -> { order("starts_at ASC") } scope :simultaneous, ->(appointment) { where(starts_at: appointment.starts_at, ends_at: appointment.ends_at).where.not(id: appointment.id) } scope :not_pulled, -> { where(pulled_at: nil) } + scope :pulled, -> { where.not(pulled_at: nil) } + scope :not_completed, -> { where(completed_at: nil) } + + # How far back the "unfinished appointments" report looks. Anything older is + # almost always a stale record whose item has long since circulated to other + # borrowers (an expired hold is a fine terminal state on its own), so we don't + # nag staff about it. + UNFINISHED_WINDOW = 30.days + + # Appointments from the last UNFINISHED_WINDOW that were pulled, never marked + # complete, and still have at least one held item that was neither checked out + # nor explicitly ended (cancelled / retired). These are where an item came off + # the shelf for a member and nobody closed the loop (#2182). What's unresolved + # is the appointment itself, which is why we key on completion rather than on + # the hold's own clock. + scope :unfinished, ->(now = Time.current) { + pulled + .not_completed + .where(starts_at: (now - UNFINISHED_WINDOW).beginning_of_day...now.beginning_of_day) + .where(id: AppointmentHold.where(hold: Hold.where(loan_id: nil, ended_at: nil)).select(:appointment_id)) + } + + def self.unfinished_window_days + (UNFINISHED_WINDOW / 1.day).to_i + end attr_accessor :member_updating, :staff_updating @@ -44,6 +69,11 @@ def completed? completed_at.present? end + # Held items on this appointment that never became a loan. + def holds_not_checked_out + holds.select { |hold| hold.loan_id.nil? } + end + def dropoff_only? holds.empty? && !loans.empty? end diff --git a/app/views/admin/appointments/_unfinished_banner.html.erb b/app/views/admin/appointments/_unfinished_banner.html.erb new file mode 100644 index 000000000..ae7167554 --- /dev/null +++ b/app/views/admin/appointments/_unfinished_banner.html.erb @@ -0,0 +1,9 @@ +<% if @unfinished_appointments_count.to_i > 0 %> +
+ <%= link_to "Review", admin_reports_unfinished_appointments_path, class: "btn btn-sm float-right" %> + <%= pluralize(@unfinished_appointments_count, "appointment") %> from the last + <%= pluralize(Appointment.unfinished_window_days, "day") %> + <%= (@unfinished_appointments_count == 1) ? "was" : "were" %> pulled but never completed, + with items that were never checked out. +
+<% end %> diff --git a/app/views/admin/appointments/index.html.erb b/app/views/admin/appointments/index.html.erb index 8615a8e99..cb8cee7f4 100644 --- a/app/views/admin/appointments/index.html.erb +++ b/app/views/admin/appointments/index.html.erb @@ -2,6 +2,8 @@ <%= index_header "Scheduled Appointments" %> <% end %> +<%= render "unfinished_banner" %> +
    diff --git a/app/views/admin/appointments/index_orig.html.erb b/app/views/admin/appointments/index_orig.html.erb index 7548ab4e4..9cef73f23 100644 --- a/app/views/admin/appointments/index_orig.html.erb +++ b/app/views/admin/appointments/index_orig.html.erb @@ -2,6 +2,8 @@ <%= index_header "Scheduled Appointments" %> <% end %> +<%= render "unfinished_banner" %> +
      diff --git a/app/views/admin/reports/unfinished_appointments/index.html.erb b/app/views/admin/reports/unfinished_appointments/index.html.erb new file mode 100644 index 000000000..1c83e89d7 --- /dev/null +++ b/app/views/admin/reports/unfinished_appointments/index.html.erb @@ -0,0 +1,56 @@ +<%= content_for :header do %> + <%= index_header "Unfinished Appointments" %> +<% end %> + +
      +
      +

      + Appointments from the last <%= pluralize(Appointment.unfinished_window_days, "day") %> + that were pulled but never marked complete, where at least one held item was + never checked out. Older appointments aren't shown here, since their items have + almost always circulated on to other borrowers by now. +

      +

      + Use the appointment or member links to figure out what happened to an item, then + Mark complete to clear the row. (You can undo that from the + appointment page if you need to.) +

      + + <% if @appointments.empty? %> + <%= empty_state "No unfinished appointments. Nice and tidy." %> + <% else %> + + + + + + + + <% @appointments.each do |appointment| %> + + + + + + + <% end %> +
      AppointmentMemberItems not checked out
      + <%= link_to l(appointment.starts_at, format: :date), admin_appointment_path(appointment) %> + + <%= link_to preferred_or_default_name(appointment.member), admin_member_path(appointment.member) %> + +
        + <% appointment.holds_not_checked_out.each do |hold| %> +
      • + <%= link_to "#{hold.item.name} (#{full_item_number(hold.item)})", admin_item_path(hold.item) %> +
      • + <% end %> +
      +
      + <%= button_to "Mark complete", complete_admin_reports_unfinished_appointment_path(appointment), + method: :post, class: "btn btn-sm", + data: {turbo_submits_with: "Marking complete..."} %> +
      + <% end %> +
      +
      diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 17d11472d..7171d00b0 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -59,6 +59,7 @@ + @@ -166,6 +167,7 @@ + diff --git a/config/routes.rb b/config/routes.rb index f1f3415bf..72d29294c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -171,6 +171,11 @@ resources :shifts, only: :index resources :items_without_image, only: :index resources :items_with_holds, only: :index + resources :unfinished_appointments, only: :index do + member do + post :complete + end + end resources :zipcodes, only: :index get "money", to: "money#index" get "members-with-overdue-loans", to: "members_with_overdue_loans#index" diff --git a/test/controllers/admin/appointments_controller_test.rb b/test/controllers/admin/appointments_controller_test.rb index db6560cb9..60e5872cf 100644 --- a/test/controllers/admin/appointments_controller_test.rb +++ b/test/controllers/admin/appointments_controller_test.rb @@ -68,5 +68,14 @@ class AppointmentsControllerTest < ActionDispatch::IntegrationTest assert_select "em", text: "Item checked-out" end + + test "banners unfinished appointments on the index when any exist" do + create(:appointment, holds: [create(:hold)], pulled_at: 2.days.ago, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + + get admin_appointments_path(day: Date.current.strftime("%F")) + + assert_select "a[href=?]", admin_reports_unfinished_appointments_path + end end end diff --git a/test/controllers/admin/reports/unfinished_appointments_controller_test.rb b/test/controllers/admin/reports/unfinished_appointments_controller_test.rb new file mode 100644 index 000000000..e935bfcb8 --- /dev/null +++ b/test/controllers/admin/reports/unfinished_appointments_controller_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +module Admin + module Reports + class UnfinishedAppointmentsControllerTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + setup do + @user = create(:admin_user) + sign_in @user + end + + test "lists pulled-but-never-completed appointments with items not checked out" do + unfinished = create(:appointment, holds: [create(:hold)], pulled_at: 2.days.ago, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + completed = create(:appointment, holds: [create(:hold)], pulled_at: 2.days.ago, + completed_at: 1.day.ago, starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + + get admin_reports_unfinished_appointments_url + assert_response :success + + assert_select "a[href=?]", admin_appointment_path(unfinished) + assert_select "a[href=?]", admin_appointment_path(completed), false, + "completed appointments should not appear" + end + + test "shows an empty state when nothing is unfinished" do + get admin_reports_unfinished_appointments_url + assert_response :success + + assert_select "table", false + end + + test "marking an appointment complete resolves it and drops it from the list" do + appointment = create(:appointment, holds: [create(:hold)], pulled_at: 2.days.ago, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + assert_includes Appointment.unfinished, appointment + + post complete_admin_reports_unfinished_appointment_url(appointment) + + assert_redirected_to admin_reports_unfinished_appointments_url + assert appointment.reload.completed? + refute_includes Appointment.unfinished, appointment + end + end + end +end diff --git a/test/models/appointment_test.rb b/test/models/appointment_test.rb index 5ad7b9394..01f3aeefd 100644 --- a/test/models/appointment_test.rb +++ b/test/models/appointment_test.rb @@ -97,6 +97,38 @@ class AppointmentTest < ActiveSupport::TestCase assert_equal not_pulled_appointments, Appointment.all.not_pulled end + test ".unfinished surfaces recent pulled, never-completed appointments with items still not checked out" do + unfinished = create(:appointment, holds: [create(:hold)], pulled_at: 2.days.ago, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + + # completed -> resolved, excluded + create(:appointment, holds: [create(:hold)], pulled_at: 2.days.ago, completed_at: 1.day.ago, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + # never pulled -> not in progress, excluded + create(:appointment, holds: [create(:hold)], pulled_at: nil, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + # pulled today -> still legitimately in progress, excluded + create(:appointment, holds: [create(:hold)], pulled_at: Time.current, + starts_at: Time.current, ends_at: 1.hour.from_now) + # older than the window -> stale, excluded + create(:appointment, holds: [create(:hold)], + pulled_at: (Appointment::UNFINISHED_WINDOW + 5.days).ago, + starts_at: (Appointment::UNFINISHED_WINDOW + 5.days).ago, + ends_at: (Appointment::UNFINISHED_WINDOW + 5.days).ago + 1.hour) + # pulled, but every held item was checked out -> nothing dangling, excluded + member = create(:member) + checked_out = create(:hold, member: member, loan: create(:loan, member: member)) + create(:appointment, holds: [checked_out], pulled_at: 2.days.ago, member: member, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + # held item resolved by an explicit end (cancelled / retired) -> excluded + ended_member = create(:member) + ended_hold = create(:hold, :ended, member: ended_member) + create(:appointment, holds: [ended_hold], pulled_at: 2.days.ago, member: ended_member, + starts_at: 2.days.ago, ends_at: 2.days.ago + 1.hour) + + assert_equal [unfinished], Appointment.unfinished + end + test "#cancel_if_no_items!" do appointment = create(:appointment_with_holds) appointment.holds.destroy_all