mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
transactions + unique-conflict
This commit is contained in:
1 parent
f8caef64c9
commit
1e0ccf53a4
4 files changed
+48
-10
No files matched your search
@@ -68,6 +68,17 @@ jobs:
|
|||||||
backend-rs/target
|
backend-rs/target
|
||||||
key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }}
|
key: cargo-${{ hashFiles('backend-rs/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Fetch common-passwords list
|
||||||
|
run: |
|
||||||
|
mkdir -p assets
|
||||||
|
curl -fsSL -o assets/common-passwords.txt.gz \
|
||||||
|
"https://raw.githubusercontent.com/django/django/${DJANGO_PASSWORDS_TAG}/django/contrib/auth/common-passwords.txt.gz"
|
||||||
|
echo "${DJANGO_PASSWORDS_SHA256} assets/common-passwords.txt.gz" | sha256sum -c -
|
||||||
|
working-directory: backend-rs
|
||||||
|
env:
|
||||||
|
DJANGO_PASSWORDS_TAG: "5.2"
|
||||||
|
DJANGO_PASSWORDS_SHA256: "3c1baed62596de36860824eb3f436d5932d37ca8b06e59df78f5a44ec175afe4"
|
||||||
|
|
||||||
- name: Format Check
|
- name: Format Check
|
||||||
run: cargo fmt --check
|
run: cargo fmt --check
|
||||||
working-directory: backend-rs
|
working-directory: backend-rs
|
||||||
|
|||||||
@@ -89,3 +89,10 @@ impl IntoResponse for ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type ApiResult<T> = Result<T, ApiError>;
|
pub type ApiResult<T> = Result<T, ApiError>;
|
||||||
|
|
||||||
|
/// True if a `DbErr` is a SQLite UNIQUE-constraint violation. SeaORM surfaces the
|
||||||
|
/// underlying sqlx message verbatim; matching on it lets handlers turn a racing
|
||||||
|
/// duplicate insert into a clean 400 instead of a generic 500.
|
||||||
|
pub fn is_unique_violation(e: &sea_orm::DbErr) -> bool {
|
||||||
|
e.to_string().contains("UNIQUE constraint failed")
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
use sea_orm::sea_query::{Expr, Func};
|
use sea_orm::sea_query::{Expr, Func};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveValue::{NotSet, Set},
|
ActiveValue::{NotSet, Set},
|
||||||
ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter,
|
ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::entities::prelude::*;
|
use crate::entities::prelude::*;
|
||||||
@@ -47,8 +47,8 @@ fn eq_or_null(col: notification::Column, value: Option<i32>) -> Condition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn parse_mentions(
|
pub async fn parse_mentions<C: ConnectionTrait>(
|
||||||
db: &DatabaseConnection,
|
db: &C,
|
||||||
text: &str,
|
text: &str,
|
||||||
actor_id: i32,
|
actor_id: i32,
|
||||||
rating_id: Option<i32>,
|
rating_id: Option<i32>,
|
||||||
|
|||||||
@@ -196,7 +196,13 @@ async fn create(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = crate::datetime::now_micros();
|
let now = crate::datetime::now_micros();
|
||||||
let model = rating::ActiveModel {
|
// Insert + mention fan-out in one transaction so a parse_mentions failure
|
||||||
|
// can't leave a rating with no notifications. The DB's UNIQUE(user_id,
|
||||||
|
// flavor_id) index is the real guard against a racing double-rate (the
|
||||||
|
// pre-check above is best-effort and not atomic): map that violation to the
|
||||||
|
// same clean validation error instead of a 500.
|
||||||
|
let txn = state.db.begin().await?;
|
||||||
|
let model = match (rating::ActiveModel {
|
||||||
id: NotSet,
|
id: NotSet,
|
||||||
user_id: Set(uid),
|
user_id: Set(uid),
|
||||||
flavor_id: Set(flavor_id),
|
flavor_id: Set(flavor_id),
|
||||||
@@ -204,12 +210,22 @@ async fn create(
|
|||||||
comment: Set(comment.clone()),
|
comment: Set(comment.clone()),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
}
|
}
|
||||||
.insert(&state.db)
|
.insert(&txn)
|
||||||
.await?;
|
.await)
|
||||||
|
{
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) if crate::error::is_unique_violation(&e) => {
|
||||||
|
return Err(ApiError::Validation(json!([
|
||||||
|
"You have already rated this flavor."
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
|
if let Some(text) = comment.as_deref().filter(|s| !s.is_empty()) {
|
||||||
parse_mentions(&state.db, text, uid, Some(model.id), None).await?;
|
parse_mentions(&txn, text, uid, Some(model.id), None).await?;
|
||||||
}
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
|
let mut dtos = build_ratings(&state, &ctx, vec![model]).await?;
|
||||||
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
||||||
@@ -430,6 +446,9 @@ async fn reply(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let now = crate::datetime::now_micros();
|
let now = crate::datetime::now_micros();
|
||||||
|
// Reply + owner-notification + mention fan-out are one unit: a failure in any
|
||||||
|
// step must not persist a partial reply (orphaned reply, missing notification).
|
||||||
|
let txn = state.db.begin().await?;
|
||||||
let reply_model = reply::ActiveModel {
|
let reply_model = reply::ActiveModel {
|
||||||
id: NotSet,
|
id: NotSet,
|
||||||
user_id: Set(uid),
|
user_id: Set(uid),
|
||||||
@@ -437,7 +456,7 @@ async fn reply(
|
|||||||
text: Set(text.to_string()),
|
text: Set(text.to_string()),
|
||||||
created_at: Set(now),
|
created_at: Set(now),
|
||||||
}
|
}
|
||||||
.insert(&state.db)
|
.insert(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if rating.user_id != uid {
|
if rating.user_id != uid {
|
||||||
@@ -453,11 +472,12 @@ async fn reply(
|
|||||||
notification_type: Set("reply".to_string()),
|
notification_type: Set("reply".to_string()),
|
||||||
profile_comment_id: Set(None),
|
profile_comment_id: Set(None),
|
||||||
}
|
}
|
||||||
.insert(&state.db)
|
.insert(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_mentions(&state.db, text, uid, Some(rating.id), Some(reply_model.id)).await?;
|
parse_mentions(&txn, text, uid, Some(rating.id), Some(reply_model.id)).await?;
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
let mut dtos = build_replies(&state, vec![reply_model]).await?;
|
let mut dtos = build_replies(&state, vec![reply_model]).await?;
|
||||||
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
let out = dtos.pop().ok_or(ApiError::Internal)?;
|
||||||
|
|||||||
Reference in new issue
Block a user