Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions Rock.Blocks/Group/Scheduling/GroupScheduler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@
using Rock.Enums.Controls;
using Rock.Model;
using Rock.Security;
using Rock.Transactions;
using Rock.Utility;
using Rock.ViewModels.Blocks.Group.Scheduling.GroupScheduler;
using Rock.ViewModels.Controls;
using Rock.ViewModels.Utility;
using Rock.Web.Cache;

namespace Rock.Blocks.Group.Scheduling
{
Expand Down Expand Up @@ -1836,14 +1838,53 @@ private GroupSchedulerSendConfirmationsResponseBag SendConfirmations( RockContex
.Where( a => attendanceOccurrenceIds.Contains( a.OccurrenceId ) )
.Where( a => a.ScheduleConfirmationSent != true );

// Make sure we save changes after calling the following method, to mark successful sends in the database
// and prevent duplicate sends the next time this method is called.
var sendMessageResult = attendanceService.SendScheduleConfirmationCommunication( sendConfirmationAttendancesQuery, true );
rockContext.SaveChanges();
// Snapshot the matching attendance IDs synchronously and defer the
// SendScheduleConfirmationCommunication + SaveChanges work to a queued
// transaction so the HTTP response returns before the reverse proxy
// can time out on large filter sets.
var attendanceIdsToSend = sendConfirmationAttendancesQuery
.Select( a => a.Id )
.ToList();

// Filter out attendance IDs that are already queued by a recent
// request (same user re-clicking, browser refresh, cross-tab, or --
// in multi-server deployments with a distributed cache configured --
// a click on another web server). The transaction releases its
// cache claim when it drains, so this only filters out work that
// is genuinely in flight.
var alreadyQueuedIds = new HashSet<int>();
foreach ( var id in attendanceIdsToSend )
{
if ( RockCache.Get( SendGroupScheduleConfirmationsTransaction.GetCacheKey( id ), SendGroupScheduleConfirmationsTransaction.CacheRegion ) != null )
{
alreadyQueuedIds.Add( id );
}
}

var newIdsToQueue = attendanceIdsToSend
.Where( id => !alreadyQueuedIds.Contains( id ) )
.ToList();

response.Errors = new List<string>();
response.Warnings = new List<string>();
response.CommunicationsSentCount = newIdsToQueue.Count;

response.Errors = sendMessageResult.Errors;
response.Warnings = sendMessageResult.Warnings;
response.CommunicationsSentCount = sendMessageResult.MessagesSent;
if ( newIdsToQueue.Any() )
{
// Claim the new IDs in the cache before enqueueing so a
// near-simultaneous re-click (or a click on another web server)
// sees them as in flight.
foreach ( var id in newIdsToQueue )
{
RockCache.AddOrUpdate(
SendGroupScheduleConfirmationsTransaction.GetCacheKey( id ),
SendGroupScheduleConfirmationsTransaction.CacheRegion,
true,
SendGroupScheduleConfirmationsTransaction.CacheTtl );
}

RockQueue.Enqueue( new SendGroupScheduleConfirmationsTransaction( newIdsToQueue ) );
}

// Check to see if any group types are missing a system communication so we can alert the current person.
var groupTypeNamesWithoutSystemCommunication = sendConfirmationAttendancesQuery
Expand All @@ -1866,7 +1907,17 @@ private GroupSchedulerSendConfirmationsResponseBag SendConfirmations( RockContex
&& !response.Errors.Any()
&& !response.Warnings.Any() )
{
response.AnyCommunicationsToSend = sendConfirmationAttendancesQuery.Any();
// If every matching attendance was filtered out by the cache
// dedup, surface the existing "already sent" message rather
// than letting the modal render with an empty body.
if ( alreadyQueuedIds.Any() )
{
response.AnyCommunicationsToSend = false;
}
else
{
response.AnyCommunicationsToSend = sendConfirmationAttendancesQuery.Any();
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,13 @@
const sendConfirmationsResponse = ref<GroupSchedulerSendConfirmationsResponseBag | null>(null);
const isSendConfirmationsModalVisible = ref(false);

// Cooldown applied to the Send Confirmations button after a successful queue,
// so a near-immediate re-click (which the server-side RockCache dedup would
// already discard) is prevented at the UI layer. Sized to cover the typical
// standard-queue drain interval plus a small execution buffer.
const sendConfirmationsCooldownSeconds = ref(0);
let sendConfirmationsCooldownIntervalId: number | null = null;

const reloadAllOccurrences = ref(false);
const reloadOccurrencesContainingResource = ref<number | null>(null);

Expand Down Expand Up @@ -582,7 +589,10 @@
let message = "";

if (sentCount.value > 0) {
message = `<p>Successfully sent ${sentCount.value} ${pluralize("confirmation", sentCount.value)}.</p>`;
// The send is now queued to a background transaction rather than performed
// synchronously, so the count reflects what has been queued. The first
// emails typically arrive within ~60 seconds.
message = `<p>${sentCount.value} ${pluralize("confirmation", sentCount.value)} queued for sending. Emails will begin arriving within about a minute.</p>`;
}
else if (!anySendErrors.value && !anySendWarnings.value && !anyCommunicationsToSend.value) {
message = "<p>Everybody has already been sent a confirmation. No additional confirmations sent.</p>";
Expand All @@ -596,7 +606,10 @@
|| isSelectingCloneSettings.value
|| isCloningSchedules.value
|| isAutoScheduling.value
|| isSendingConfirmations.value;
|| isSendingConfirmations.value
// Keep Send Confirmations (and sibling scheduling actions) disabled while
// the cooldown is active so re-clicks cannot queue duplicate work.
|| sendConfirmationsCooldownSeconds.value > 0;
});

const isSchedulingDisabled = computed((): boolean => {
Expand Down Expand Up @@ -1028,6 +1041,34 @@
isSendConfirmationsReviewModalVisible.value = true;
}

/**
* Starts the Send Confirmations cooldown. The button (and the sibling
* scheduling actions, via `isBusy`) stays disabled until the cooldown
* reaches zero. Works in concert with the server-side RockCache dedup to
* prevent duplicate queueing on near-immediate re-clicks.
*/
function startSendConfirmationsCooldown(seconds: number): void {
sendConfirmationsCooldownSeconds.value = seconds;

if (sendConfirmationsCooldownIntervalId !== null) {
window.clearInterval(sendConfirmationsCooldownIntervalId);
sendConfirmationsCooldownIntervalId = null;
}

sendConfirmationsCooldownIntervalId = window.setInterval(() => {
sendConfirmationsCooldownSeconds.value--;

if (sendConfirmationsCooldownSeconds.value <= 0) {
sendConfirmationsCooldownSeconds.value = 0;

if (sendConfirmationsCooldownIntervalId !== null) {
window.clearInterval(sendConfirmationsCooldownIntervalId);
sendConfirmationsCooldownIntervalId = null;
}
}
}, 1000);
}

/**
* Sends confirmations based on the currently-applied filters.
*/
Expand All @@ -1052,6 +1093,13 @@

sendConfirmationsResponse.value = result.data;
isSendConfirmationsModalVisible.value = true;

// Only start the cooldown when this click actually queued new work. If
// everything was dedup'd (count == 0) the cache is already protecting us,
// and if no matching records existed there is nothing in flight to wait for.
if ((result.data?.communicationsSentCount ?? 0) > 0) {
startSendConfirmationsCooldown(90);
}
}

/**
Expand Down Expand Up @@ -1137,5 +1185,12 @@
if (schedulesResizeObserver) {
schedulesResizeObserver.disconnect();
}

// Clear the Send Confirmations cooldown interval if the component
// unmounts while the cooldown is active (route change, page navigation, etc.).
if (sendConfirmationsCooldownIntervalId !== null) {
window.clearInterval(sendConfirmationsCooldownIntervalId);
sendConfirmationsCooldownIntervalId = null;
}
});
</script>
154 changes: 154 additions & 0 deletions Rock/Transactions/SendGroupScheduleConfirmationsTransaction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// <copyright>
// Copyright by the Spark Development Network
//
// Licensed under the Rock Community License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.rockrms.com/license
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// </copyright>
//
using System;
using System.Collections.Generic;
using System.Linq;

using Rock.Data;
using Rock.Model;
using Rock.Web.Cache;

namespace Rock.Transactions
{
/// <summary>
/// Sends Group Scheduler confirmation communications for a snapshotted set of
/// <see cref="Attendance"/> records on the standard transaction queue. The
/// caller (typically the Group Scheduler block's <c>SendConfirmations</c>
/// action) is expected to perform pre-flight validation synchronously and
/// enqueue this transaction so the HTTP request can return immediately,
/// avoiding reverse-proxy timeouts on large sends.
/// </summary>
public class SendGroupScheduleConfirmationsTransaction : ITransaction
{
#region Cache Coordination

/// <summary>
/// The <see cref="RockCache"/> region used to coordinate "currently queued"
/// attendance identifiers between the block action (which writes claims)
/// and this transaction (which releases them after drain).
/// </summary>
internal const string CacheRegion = "GroupScheduler";

/// <summary>
/// How long a queued claim is held in cache. The cache release in
/// <see cref="Execute"/> normally clears entries well before this TTL;
/// the TTL is the safety net for the case where a worker terminates
/// before reaching the release in the <c>finally</c> block.
/// </summary>
internal static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes( 10 );

/// <summary>
/// Builds the <see cref="RockCache"/> key for tracking a single queued
/// <see cref="Attendance"/> identifier.
/// </summary>
/// <param name="attendanceId">The Attendance identifier.</param>
/// <returns>The cache key string for this attendance identifier.</returns>
internal static string GetCacheKey( int attendanceId )
{
return $"AttendanceConfirmationQueued:{attendanceId}";
}

#endregion

/// <summary>
/// Gets or sets the identifiers of the <see cref="Attendance"/> records
/// for which confirmations should be sent. The transaction re-filters to
/// records that have not yet had
/// <see cref="Attendance.ScheduleConfirmationSent"/> set, so it is safe
/// (and idempotent) if multiple instances are enqueued for the same IDs.
/// </summary>
/// <value>
/// The list of <see cref="Attendance"/> identifiers to send confirmations for.
/// </value>
public List<int> AttendanceIds { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="SendGroupScheduleConfirmationsTransaction"/> class.
/// </summary>
/// <param name="attendanceIds">The identifiers of the Attendance records to send confirmations for.</param>
public SendGroupScheduleConfirmationsTransaction( IEnumerable<int> attendanceIds )
{
AttendanceIds = attendanceIds?.ToList() ?? new List<int>();
}

/// <summary>
/// Executes the send on the background queue.
/// </summary>
public void Execute()
{
if ( AttendanceIds == null || !AttendanceIds.Any() )
{
return;
}

try
{
using ( var rockContext = new RockContext() )
{
var attendanceService = new AttendanceService( rockContext );

// Re-apply the same base predicates the caller's snapshot relied on
// (RequestedToAttend, DeclineReasonValueId, DidAttend, future
// OccurrenceDate, RSVP / ScheduledToAttend / AutoAccept rules) so an
// Attendance whose state changed between snapshot and drain (e.g.,
// the volunteer declined) is skipped rather than receiving a stale
// confirmation.
var sendConfirmationAttendancesQuery = attendanceService.GetPendingAndAutoAcceptScheduledConfirmations()
.Where( a => AttendanceIds.Contains( a.Id ) )
.Where( a => a.ScheduleConfirmationSent != true );

var sendMessageResult = attendanceService.SendScheduleConfirmationCommunication( sendConfirmationAttendancesQuery, true );

rockContext.SaveChanges();

// Per-recipient errors would normally surface in the UI for a
// synchronous send. Because this transaction runs after the HTTP
// response has returned, log them so they remain discoverable via
// the Exception List page.
if ( sendMessageResult?.Errors != null && sendMessageResult.Errors.Any() )
{
ExceptionLogService.LogException(
new Exception( $"Group Scheduler background confirmation send completed with {sendMessageResult.Errors.Count} error(s): {string.Join( " | ", sendMessageResult.Errors )}" )
);
}
}
}
catch ( Exception ex )
{
ExceptionLogService.LogException( new Exception( $"Exception in {nameof( SendGroupScheduleConfirmationsTransaction )}.Execute() for {AttendanceIds.Count} attendance id(s).", ex ) );
}
finally
{
// Release the cache claims regardless of success or failure. The TTL
// will eventually clear stale entries if this is skipped (e.g. process
// crash), but explicit release keeps the cache footprint small under
// normal load.
foreach ( var id in AttendanceIds )
{
try
{
RockCache.Remove( GetCacheKey( id ), CacheRegion );
}
catch
{
// Intentionally swallowed -- cache cleanup must not propagate failures.
}
}
}
}
}
}