Skip to content
Merged
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
7 changes: 7 additions & 0 deletions backend/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),

#[error("Conflict: {0}")]
Conflict(String),

#[error("Unauthorized: {0}")]
Unauthorized(String),

Expand Down Expand Up @@ -70,6 +73,10 @@ impl IntoResponse for AppError {
(StatusCode::BAD_REQUEST, msg.clone())
}
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Conflict(msg) => {
tracing::warn!("Conflict: {}", msg);
(StatusCode::CONFLICT, msg.clone())
}
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::FileTooLarge(max) => (
Expand Down
132 changes: 131 additions & 1 deletion backend/src/handlers/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2819,6 +2819,56 @@ pub async fn api_show_detail(
}))
}

/// Whether two same-day time windows clash. Times are zero-padded "HH:MM", so
/// lexical comparison matches chronological order. A missing end is treated as a
/// zero-length point at its start; identical starts always clash (catches the
/// "two shows booked at the same time" case even when end times are absent).
fn time_windows_overlap(
start_a: &str,
end_a: Option<&str>,
start_b: &str,
end_b: Option<&str>,
) -> bool {
let end_a = end_a.unwrap_or(start_a);
let end_b = end_b.unwrap_or(start_b);
// Half-open overlap [start, end), plus an explicit equal-start collision.
(start_a < end_b && start_b < end_a) || start_a == start_b
}

/// Reject if the proposed window overlaps another show on the same date.
/// `exclude_id` skips the show being edited (so it never clashes with itself).
/// No start time → nothing to check (the window is undefined).
async fn ensure_no_show_conflict(
state: &AppState,
date: &str,
start_time: Option<&str>,
end_time: Option<&str>,
exclude_id: Option<i64>,
) -> Result<()> {
let Some(start) = start_time else {
return Ok(());
};

let others: Vec<(String, String, Option<String>)> = sqlx::query_as(
"SELECT title, start_time, end_time FROM shows \
WHERE date = ? AND id != ? AND start_time IS NOT NULL",
)
.bind(date)
.bind(exclude_id.unwrap_or(-1))
.fetch_all(&state.db)
.await?;

for (title, other_start, other_end) in others {
if time_windows_overlap(start, end_time, &other_start, other_end.as_deref()) {
return Err(AppError::Conflict(format!(
"This time overlaps an existing show \"{}\" on {}",
title, date
)));
}
}
Ok(())
}

#[derive(Debug, Deserialize)]
pub struct CreateShowRequest {
title: String,
Expand Down Expand Up @@ -2929,6 +2979,17 @@ pub async fn api_create_show(
_ => "live",
};

// Refuse to double-book a slot: the new window must not overlap another show
// on the same date.
ensure_no_show_conflict(
&state,
&req.date,
req.start_time.as_deref(),
end_time.as_deref(),
None,
)
.await?;

let result = sqlx::query(
"INSERT INTO shows (title, date, description, status, show_type, start_time, end_time, host_user_id, stream_mode, created_by) VALUES (?, ?, ?, 'scheduled', ?, ?, ?, ?, ?, ?)",
)
Expand Down Expand Up @@ -3172,7 +3233,7 @@ pub async fn api_update_show(
Json(req): Json<UpdateShowRequest>,
) -> Result<impl IntoResponse> {
// Admins (any show) or the assigned host (their own show) may update.
let (user, _show) = require_show_editor(&state, &headers, id).await?;
let (user, existing) = require_show_editor(&state, &headers, id).await?;

// show_type and ai_bio are admin-only fields; hosts have no UI for them.
if !user.role_enum().can_access_admin() && (req.show_type.is_some() || req.ai_bio.is_some()) {
Expand Down Expand Up @@ -3234,6 +3295,15 @@ pub async fn api_update_show(
return Err(AppError::BadRequest("No fields to update".to_string()));
}

// If the schedule window is being touched, make sure it doesn't collide with
// another show. Fall back to the show's current values for fields left unset.
if req.date.is_some() || req.start_time.is_some() || req.end_time.is_some() {
let date = req.date.as_deref().unwrap_or(&existing.date);
let start_time = req.start_time.as_deref().or(existing.start_time.as_deref());
let end_time = req.end_time.as_deref().or(existing.end_time.as_deref());
ensure_no_show_conflict(&state, date, start_time, end_time, Some(id)).await?;
}

let query_str = format!("UPDATE shows SET {} WHERE id = ?", updates.join(", "));
let mut query = sqlx::query(&query_str);
for bind in &binds {
Expand Down Expand Up @@ -5496,3 +5566,63 @@ pub async fn require_show_editor(

Ok((user, show))
}

#[cfg(test)]
mod tests {
use super::time_windows_overlap;

#[test]
fn distinct_windows_do_not_overlap() {
// 20:00–22:00 vs 22:00–23:00: touch at the boundary, half-open → no clash.
assert!(!time_windows_overlap(
"20:00",
Some("22:00"),
"22:00",
Some("23:00")
));
// Fully separate.
assert!(!time_windows_overlap(
"18:00",
Some("19:00"),
"20:00",
Some("21:00")
));
}

#[test]
fn partially_overlapping_windows_clash() {
assert!(time_windows_overlap(
"20:00",
Some("22:00"),
"21:00",
Some("23:00")
));
// Containment.
assert!(time_windows_overlap(
"20:00",
Some("23:00"),
"21:00",
Some("22:00")
));
}

#[test]
fn identical_start_always_clashes() {
// Same start, even with no end times recorded.
assert!(time_windows_overlap("20:00", None, "20:00", None));
assert!(time_windows_overlap(
"20:00",
Some("22:00"),
"20:00",
Some("21:00")
));
}

#[test]
fn missing_end_is_a_point_inside_another_window() {
// A start-only show at 21:00 falls inside 20:00–22:00.
assert!(time_windows_overlap("21:00", None, "20:00", Some("22:00")));
// ...but a point at 22:00 is outside the half-open [20:00, 22:00).
assert!(!time_windows_overlap("22:00", None, "20:00", Some("22:00")));
}
}
66 changes: 66 additions & 0 deletions frontend/src/admin/__tests__/showConflict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { rangesOverlap, findConflictingShow } from '../showConflict';
import type { ScheduleItem } from '../api';

const at = (h: number, m = 0) => new Date(2026, 5, 1, h, m);

function show(partial: Partial<ScheduleItem> & Pick<ScheduleItem, 'id' | 'date'>): ScheduleItem {
return {
title: 'Show',
start_time: undefined,
end_time: undefined,
status: 'scheduled',
show_type: 'external',
artists: [],
...partial,
} as ScheduleItem;
}

describe('rangesOverlap', () => {
it('treats boundary-touching windows as free (half-open)', () => {
expect(rangesOverlap(at(20), at(22), at(22), at(23))).toBe(false);
});

it('detects partial overlap and containment', () => {
expect(rangesOverlap(at(20), at(22), at(21), at(23))).toBe(true);
expect(rangesOverlap(at(20), at(23), at(21), at(22))).toBe(true);
});

it('treats identical starts as a clash even without end times', () => {
expect(rangesOverlap(at(20), null, at(20), null)).toBe(true);
});

it('treats a start-only point inside another window as a clash', () => {
expect(rangesOverlap(at(21), null, at(20), at(22))).toBe(true);
expect(rangesOverlap(at(22), null, at(20), at(22))).toBe(false);
});
});

describe('findConflictingShow', () => {
const shows: ScheduleItem[] = [
show({ id: 1, date: '2026-06-01', start_time: '20:00', end_time: '22:00', title: 'Evening' }),
show({ id: 2, date: '2026-06-02', start_time: '20:00', end_time: '22:00', title: 'Other day' }),
];

it('returns null when the slot is free', () => {
expect(findConflictingShow(at(22), at(23), shows)).toBeNull();
});

it('returns the clashing show on the same day', () => {
expect(findConflictingShow(at(21), at(23), shows)?.id).toBe(1);
});

it('ignores shows on other days', () => {
// 20:00–22:00 on June 1 does not clash with the June 2 show.
expect(findConflictingShow(at(20), at(22), [shows[1]])).toBeNull();
});

it('skips the excluded show (e.g. the one being edited)', () => {
expect(findConflictingShow(at(20), at(22), shows, 1)).toBeNull();
});

it('ignores shows without a start time', () => {
const noTime = [show({ id: 3, date: '2026-06-01', title: 'TBD' })];
expect(findConflictingShow(at(20), at(22), noTime)).toBeNull();
});
});
Loading
Loading