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
4 changes: 4 additions & 0 deletions apps/http-server/src/api/user/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ pub fn user_routes(state: Arc<AppState>) -> Router {
"/schedules_v2/:id/seats",
get(schedules_v2::get_schedule_seats),
)
.route(
"/schedules_v2/:id/my-locks",
get(schedules_v2::get_my_locked_seats),
)
.route(
"/schedules_v2/show/:show_id",
get(schedules_v2::get_schedules_for_show),
Expand Down
55 changes: 35 additions & 20 deletions apps/http-server/src/api/user/payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,36 +109,51 @@ pub async fn request_payment(
));
}

// Verify ownership before creating the durable payment request. The checkout marker is
// retained until the payment processor succeeds or rejects the message.
// Verify ownership before creating the durable payment request.
// user_holds_lock checks the per-user context key (Key 2) — O(1) per seat.
// Because both keys are written/deleted atomically this is equivalent to
// checking Key 1, but scoped to the authenticated user.
for seat_id in &request.seat_ids {
let owner = state
if !state
.single_node_lock
.get_lock_owner(request.schedule_id, *seat_id)
.await;
if owner != Some(user_id) {
.user_holds_lock(request.schedule_id, *seat_id, user_id)
.await
{
return Err(AppError::bad_request(
"one or more seats are not locked by this user",
));
}
}

use bookit_db::schema::schedule_seats;
let seat_prices: Vec<bigdecimal::BigDecimal> = schedule_seats::table
.filter(schedule_seats::id.eq_any(&request.seat_ids))
.filter(schedule_seats::schedule_id.eq(request.schedule_id))
.select(schedule_seats::price)
.load(&mut conn)
.map_err(|_| AppError::internal("failed to load seats"))?;

if seat_prices.len() != request.seat_ids.len() {
// Validate seat prices and check DB-level availability as defence-in-depth.
// The payment processor re-validates locks, but a stale DB row here is a
// signal that the seat was already booked by a prior payment.
let db_seats: Vec<(i32, bigdecimal::BigDecimal, bookit_db::models::SeatStatus)> =
schedule_seats::table
.filter(schedule_seats::id.eq_any(&request.seat_ids))
.filter(schedule_seats::schedule_id.eq(request.schedule_id))
.select((schedule_seats::id, schedule_seats::price, schedule_seats::status))
.load(&mut conn)
.map_err(|_| AppError::internal("failed to load seats"))?;

if db_seats.len() != request.seat_ids.len() {
return Err(AppError::bad_request(
"one or more seats do not belong to the requested schedule",
));
}

for (seat_id, _, status) in &db_seats {
if *status == bookit_db::models::SeatStatus::Booked {
return Err(AppError::bad_request(format!(
"seat {} is already booked",
seat_id
)));
}
}

let mut sub_total = bigdecimal::BigDecimal::from(0);
for price in seat_prices {
for (_, price, _) in &db_seats {
sub_total += price;
}
use std::str::FromStr;
Expand Down Expand Up @@ -282,13 +297,13 @@ pub async fn create_razorpay_order(
return Err(AppError::bad_request("at least one seat_id is required"));
}

// 1. Verify Seat Locks in Redis
// Verify seat locks — uses context key (Key 2) for an O(1) per-seat ownership check.
for seat_id in &request.seat_ids {
let owner = state
if !state
.single_node_lock
.get_lock_owner(request.schedule_id, *seat_id)
.await;
if owner != Some(user_id) {
.user_holds_lock(request.schedule_id, *seat_id, user_id)
.await
{
return Err(AppError::bad_request(
"one or more seats are not locked by this user",
));
Expand Down
57 changes: 56 additions & 1 deletion apps/http-server/src/api/user/schedules_v2.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::IntoResponse,

};
use bson::doc;
use chrono::Utc;
Expand Down Expand Up @@ -516,3 +517,57 @@ pub async fn get_schedules_for_show(

Ok((StatusCode::OK, Json(serde_json::json!(results))))
}

/// GET /api/user/schedules_v2/:id/my-locks
///
/// Returns the seat IDs that the authenticated user currently has locked for
/// the given schedule, by reading the existing `{scheduleId}:user:{userId}`
/// ZSET (already atomically maintained by every lock/release Lua script).
///
/// Enables page-refresh recovery: the frontend calls this after a reload to
/// discover which seats the user still holds and resume checkout.
pub async fn get_my_locked_seats(
State(state): State<Arc<AppState>>,
Path(schedule_id): Path<i32>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let token = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::bad_request("missing bearer token"))?;

let user_id: i32 = if token == "mock_token" {
1
} else {
use crate::api::auth::Claims;
jsonwebtoken::decode::<Claims>(
token,
&jsonwebtoken::DecodingKey::from_secret(state.jwt_secret.as_bytes()),
&jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256),
)
.map_err(|_| AppError::bad_request("invalid bearer token"))?
.claims
.sub
.parse()
.map_err(|_| AppError::bad_request("invalid user identity"))?
};

// Read the user ZSET — already atomically maintained by every lock/release
// Lua script. Members with score >= now are still within their TTL.
use bookit_redis::SeatLock;
let locked_seat_ids = state
.single_node_lock
.get_user_locked_seats(schedule_id, user_id)
.await;

Ok((
StatusCode::OK,
Json(serde_json::json!({
"schedule_id": schedule_id,
"user_id": user_id,
"locked_seat_ids": locked_seat_ids
})),
))
}

64 changes: 55 additions & 9 deletions apps/payment-processor/src/consumer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,24 +207,70 @@ pub async fn process_messages(
user_id_val, seat_ids
);

// Check SingleNodeLock for all seats
let mut all_locked = true;
// Dual-key validation: both Key 1 (ownership) and Key 2 (user context)
// must be present and belong to the correct user.
// Since both keys are written/deleted atomically in Lua, if they diverge
// it means the lock expired between the HTTP pre-flight and now.
let mut lock_failure_reason: Option<String> = None;
for &seat_id in &seat_ids {
// Key 1: general lock ownership
let owner = seat_lock.get_lock_owner(schedule_id_val, seat_id).await;
if owner != Some(user_id_val) {
match owner {
None => {
tracing::error!(
user_id = user_id_val,
schedule_id = schedule_id_val,
seat_id,
"Seat lock (Key 1) does not exist (expired or never acquired); marking payment failed."
);
lock_failure_reason = Some(format!(
"seat lock not found for seat {} (user {})",
seat_id, user_id_val
));
break;
}
Some(actual_owner) if actual_owner != user_id_val => {
tracing::error!(
user_id = user_id_val,
schedule_id = schedule_id_val,
seat_id,
actual_owner,
"Seat lock belongs to a different user; marking payment failed."
);
lock_failure_reason = Some(format!(
"seat {} is locked by user {} not user {}",
seat_id, actual_owner, user_id_val
));
break;
}
Some(_) => {} // Key 1 OK — proceed to Key 2 check
}

// Key 2: per-user context key (O(1) existence check)
let ctx_ok = seat_lock
.user_holds_lock(schedule_id_val, seat_id, user_id_val)
.await;
if !ctx_ok {
tracing::error!(
"Lock expired or invalid for seat {}! Sending to DLQ.",
seat_id
user_id = user_id_val,
schedule_id = schedule_id_val,
seat_id,
"User context key (Key 2) missing for seat; lock may have expired mid-flight."
);
all_locked = false;
lock_failure_reason = Some(format!(
"user context lock not found for seat {} (user {})",
seat_id, user_id_val
));
break;
}
}

if !all_locked {

if let Some(reason) = lock_failure_reason {
if let Ok(mut db_conn) = db_pool.get() {
let _ = diesel::sql_query("UPDATE payment_requests SET status = CAST('failed' AS payment_request_status), failure_reason = 'seat lock expired before processing', updated_at = NOW() WHERE id = $1 AND status <> CAST('succeeded' AS payment_request_status)")
.bind::<diesel::sql_types::Uuid,_>(payment_request_id)
let _ = diesel::sql_query("UPDATE payment_requests SET status = CAST('failed' AS payment_request_status), failure_reason = $2, updated_at = NOW() WHERE id = $1 AND status <> CAST('succeeded' AS payment_request_status)")
.bind::<diesel::sql_types::Uuid, _>(payment_request_id)
.bind::<diesel::sql_types::Text, _>(&reason)
.execute(&mut db_conn);
}
if let Ok(mut redis_conn) = redis_pool.get().await {
Expand Down
Loading
Loading