fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:
- Offline upload queue auto-resumes on reconnect: network errors keep items
pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
re-release regenerates the keepsake; workers claim their job atomically so a
reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
live; feed_delta rate-limited; like returns {liked, like_count} to fix
multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
quota increment is transactional; operator floor.
Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::middleware::AuthUser;
|
||||
@@ -12,11 +12,22 @@ use crate::models::hashtag::{self, Hashtag};
|
||||
use crate::models::upload::Upload;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LikeResponse {
|
||||
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
|
||||
/// this rather than blind-inverting local state — otherwise a second device (same
|
||||
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
|
||||
pub liked: bool,
|
||||
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
|
||||
/// keeps its current count when this is null rather than adopting a wrong number.
|
||||
pub like_count: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn toggle_like(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(upload_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
) -> Result<Json<LikeResponse>, AppError> {
|
||||
// Check if user is banned
|
||||
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
@@ -35,7 +46,7 @@ pub async fn toggle_like(
|
||||
// ("Event schließen") freezes *new uploads* only — likes, comments and
|
||||
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
|
||||
|
||||
// Try to insert; if conflict, delete (toggle)
|
||||
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
|
||||
ON CONFLICT (upload_id, user_id) DO NOTHING",
|
||||
@@ -45,7 +56,8 @@ pub async fn toggle_like(
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let liked = result.rows_affected() > 0;
|
||||
if !liked {
|
||||
// Already liked — remove
|
||||
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
|
||||
.bind(upload_id)
|
||||
@@ -55,24 +67,29 @@ pub async fn toggle_like(
|
||||
}
|
||||
|
||||
// Fresh count so feed clients can patch the single card in place instead of
|
||||
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The
|
||||
// count + broadcast are a UI optimisation — the like itself is already committed,
|
||||
// so a failure here must not fail the request. Swallow the error and skip the
|
||||
// broadcast; the next event or a pull-to-refresh reconciles the count.
|
||||
if let Ok(like_count) = sqlx::query_scalar::<_, i64>(
|
||||
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
|
||||
// itself is already committed, so a failed count must not fail the request — but we
|
||||
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
|
||||
// client until the next event). On error we skip the broadcast and return null.
|
||||
let like_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
|
||||
)
|
||||
.bind(upload_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
{
|
||||
.ok();
|
||||
|
||||
if let Some(count) = like_count {
|
||||
// Broadcast the new count so other clients patch their card. Only `like_count` is
|
||||
// shared — each client's own `liked_by_me` only changes via its own toggle (which
|
||||
// now reads it straight from this response).
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
||||
event_type: "like-update".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "like_count": like_count }).to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
Ok(Json(LikeResponse { liked, like_count }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
|
||||
Reference in New Issue
Block a user