@@ -10139,6 +10139,12 @@ async fn compute_slots(
1013910139 &overrides,
1014010140 );
1014110141
10142+ // Drop slots whose host-local period already meets/exceeds a configured
10143+ // frequency cap. The submit-time check (would_exceed_frequency_limit)
10144+ // stays as a backstop for the race where two guests grab the last slot
10145+ // at the same instant.
10146+ apply_frequency_limit_filter(pool, et_id, &mut result).await;
10147+
1014210148 // If first_slot_only is enabled, keep only the earliest slot per day
1014310149 let first_only: i32 =
1014410150 sqlx::query_scalar("SELECT first_slot_only FROM event_types WHERE id = ?")
@@ -10156,6 +10162,73 @@ async fn compute_slots(
1015610162 result
1015710163}
1015810164
10165+ /// Remove slots that fall inside a host-local period already at/over a
10166+ /// configured booking-frequency cap, so the picker doesn't surface times
10167+ /// that the submit-time check would reject.
10168+ async fn apply_frequency_limit_filter(pool: &SqlitePool, et_id: &str, days: &mut [SlotDay]) {
10169+ let limits: Vec<(i32, String)> = sqlx::query_as(
10170+ "SELECT max_bookings, period FROM booking_frequency_limits WHERE event_type_id = ?",
10171+ )
10172+ .bind(et_id)
10173+ .fetch_all(pool)
10174+ .await
10175+ .unwrap_or_default();
10176+
10177+ if limits.is_empty() {
10178+ return;
10179+ }
10180+
10181+ // Gather every (limit, period_start) pair that any visible slot belongs to,
10182+ // then resolve each unique (period, period_start) to a count. The same
10183+ // period covers many slots, so we collapse before querying.
10184+ let mut needed: HashMap<(String, NaiveDateTime), i64> = HashMap::new();
10185+ for day in days.iter() {
10186+ for slot in &day.slots {
10187+ let Ok(d) = NaiveDate::parse_from_str(&slot.host_date, "%Y-%m-%d") else {
10188+ continue;
10189+ };
10190+ let dt = d.and_hms_opt(12, 0, 0).unwrap();
10191+ for (_, period) in &limits {
10192+ let (ps, _) = frequency_period_range(dt, period);
10193+ needed.entry((period.clone(), ps)).or_insert(0);
10194+ }
10195+ }
10196+ }
10197+
10198+ for ((period, ps), count) in needed.iter_mut() {
10199+ let (rs, re) = frequency_period_range(*ps, period);
10200+ let rs_str = rs.format("%Y-%m-%dT%H:%M:%S").to_string();
10201+ let re_str = re.format("%Y-%m-%dT%H:%M:%S").to_string();
10202+ let c: (i64,) = sqlx::query_as(
10203+ "SELECT COUNT(*) FROM bookings WHERE event_type_id = ? AND status IN ('confirmed', 'pending') AND start_at >= ? AND start_at < ?",
10204+ )
10205+ .bind(et_id)
10206+ .bind(&rs_str)
10207+ .bind(&re_str)
10208+ .fetch_one(pool)
10209+ .await
10210+ .unwrap_or((0,));
10211+ *count = c.0;
10212+ }
10213+
10214+ for day in days.iter_mut() {
10215+ day.slots.retain(|slot| {
10216+ let Ok(d) = NaiveDate::parse_from_str(&slot.host_date, "%Y-%m-%d") else {
10217+ return true;
10218+ };
10219+ let dt = d.and_hms_opt(12, 0, 0).unwrap();
10220+ for (max, period) in &limits {
10221+ let (ps, _) = frequency_period_range(dt, period);
10222+ let count = needed.get(&(period.clone(), ps)).copied().unwrap_or(0);
10223+ if count >= *max as i64 {
10224+ return false;
10225+ }
10226+ }
10227+ true
10228+ });
10229+ }
10230+ }
10231+
1015910232/// Save booking frequency limits from the serialized form field ("1:day,5:week").
1016010233async fn save_frequency_limits(pool: &SqlitePool, event_type_id: &str, limits_str: &str) {
1016110234 if limits_str.is_empty() {
@@ -15756,6 +15829,141 @@ mod tests {
1575615829 );
1575715830 }
1575815831
15832+ #[tokio::test]
15833+ async fn compute_slots_frequency_limit_per_day_drops_capped_day() {
15834+ let pool = setup_test_db().await;
15835+ let (_, _, et_id) = seed_test_data(&pool).await;
15836+
15837+ let now = Utc::now().with_timezone(&Tz::UTC).naive_local();
15838+ let mut next_monday = now.date() + Duration::days(1);
15839+ while next_monday.weekday() != chrono::Weekday::Mon {
15840+ next_monday += Duration::days(1);
15841+ }
15842+ let days_to_monday = (next_monday - now.date()).num_days() as i32;
15843+
15844+ sqlx::query("INSERT INTO booking_frequency_limits (id, event_type_id, max_bookings, period) VALUES (?, ?, 1, 'day')")
15845+ .bind(uuid::Uuid::new_v4().to_string())
15846+ .bind(&et_id)
15847+ .execute(&pool).await.unwrap();
15848+
15849+ let start_at = next_monday
15850+ .and_hms_opt(10, 0, 0)
15851+ .unwrap()
15852+ .format("%Y-%m-%dT%H:%M:%S")
15853+ .to_string();
15854+ let end_at = next_monday
15855+ .and_hms_opt(10, 30, 0)
15856+ .unwrap()
15857+ .format("%Y-%m-%dT%H:%M:%S")
15858+ .to_string();
15859+ sqlx::query("INSERT INTO bookings (id, event_type_id, uid, guest_name, guest_email, guest_timezone, start_at, end_at, status, cancel_token, reschedule_token) VALUES (?, ?, 'uid1', 'Guest', 'g@e.com', 'UTC', ?, ?, 'confirmed', ?, ?)")
15860+ .bind(uuid::Uuid::new_v4().to_string())
15861+ .bind(&et_id)
15862+ .bind(&start_at)
15863+ .bind(&end_at)
15864+ .bind(uuid::Uuid::new_v4().to_string())
15865+ .bind(uuid::Uuid::new_v4().to_string())
15866+ .execute(&pool).await.unwrap();
15867+
15868+ let slot_days = compute_slots(
15869+ &pool,
15870+ &et_id,
15871+ 30,
15872+ 0,
15873+ 0,
15874+ 0,
15875+ days_to_monday,
15876+ 5,
15877+ Tz::UTC,
15878+ Tz::UTC,
15879+ BusySource::Individual(vec![]),
15880+ )
15881+ .await;
15882+
15883+ let monday_date = next_monday.format("%Y-%m-%d").to_string();
15884+ let monday = slot_days.iter().find(|d| d.date == monday_date);
15885+ assert!(
15886+ monday.map(|d| d.slots.is_empty()).unwrap_or(true),
15887+ "1/day cap reached → Monday should expose no slots, got {:?}",
15888+ monday.map(|d| d.slots.len())
15889+ );
15890+
15891+ let tuesday_date = (next_monday + Duration::days(1))
15892+ .format("%Y-%m-%d")
15893+ .to_string();
15894+ let tuesday = slot_days
15895+ .iter()
15896+ .find(|d| d.date == tuesday_date)
15897+ .expect("Tuesday should be present");
15898+ assert_eq!(
15899+ tuesday.slots.len(),
15900+ 16,
15901+ "Tuesday is unaffected by Monday's per-day cap"
15902+ );
15903+ }
15904+
15905+ #[tokio::test]
15906+ async fn compute_slots_frequency_limit_per_week_drops_whole_week() {
15907+ let pool = setup_test_db().await;
15908+ let (_, _, et_id) = seed_test_data(&pool).await;
15909+
15910+ let now = Utc::now().with_timezone(&Tz::UTC).naive_local();
15911+ let mut next_monday = now.date() + Duration::days(1);
15912+ while next_monday.weekday() != chrono::Weekday::Mon {
15913+ next_monday += Duration::days(1);
15914+ }
15915+ let days_to_monday = (next_monday - now.date()).num_days() as i32;
15916+
15917+ sqlx::query("INSERT INTO booking_frequency_limits (id, event_type_id, max_bookings, period) VALUES (?, ?, 1, 'week')")
15918+ .bind(uuid::Uuid::new_v4().to_string())
15919+ .bind(&et_id)
15920+ .execute(&pool).await.unwrap();
15921+
15922+ // One booking on Monday consumes the week's only slot.
15923+ let start_at = next_monday
15924+ .and_hms_opt(10, 0, 0)
15925+ .unwrap()
15926+ .format("%Y-%m-%dT%H:%M:%S")
15927+ .to_string();
15928+ let end_at = next_monday
15929+ .and_hms_opt(10, 30, 0)
15930+ .unwrap()
15931+ .format("%Y-%m-%dT%H:%M:%S")
15932+ .to_string();
15933+ sqlx::query("INSERT INTO bookings (id, event_type_id, uid, guest_name, guest_email, guest_timezone, start_at, end_at, status, cancel_token, reschedule_token) VALUES (?, ?, 'uid1', 'Guest', 'g@e.com', 'UTC', ?, ?, 'confirmed', ?, ?)")
15934+ .bind(uuid::Uuid::new_v4().to_string())
15935+ .bind(&et_id)
15936+ .bind(&start_at)
15937+ .bind(&end_at)
15938+ .bind(uuid::Uuid::new_v4().to_string())
15939+ .bind(uuid::Uuid::new_v4().to_string())
15940+ .execute(&pool).await.unwrap();
15941+
15942+ let slot_days = compute_slots(
15943+ &pool,
15944+ &et_id,
15945+ 30,
15946+ 0,
15947+ 0,
15948+ 0,
15949+ days_to_monday,
15950+ 5,
15951+ Tz::UTC,
15952+ Tz::UTC,
15953+ BusySource::Individual(vec![]),
15954+ )
15955+ .await;
15956+
15957+ for day in &slot_days {
15958+ assert!(
15959+ day.slots.is_empty(),
15960+ "Per-week cap of 1 reached → {} should have no slots, got {}",
15961+ day.date,
15962+ day.slots.len()
15963+ );
15964+ }
15965+ }
15966+
1575915967 #[tokio::test]
1576015968 async fn compute_slots_no_weekend_slots() {
1576115969 let pool = setup_test_db().await;
0 commit comments