feat: unique display names (case-insensitive) + inline recover on join

Backend:
- Migration 007 deduplicates existing users by name (case-insensitive,
  keeping oldest) and adds a unique index on (event_id, LOWER(display_name))
- find_by_event_and_name is now case-insensitive (LOWER comparison)
- join endpoint checks name availability before creating; returns 409
  Conflict with code "conflict" when the name is taken

Frontend (join page):
- On 409, switches to a name-taken view showing an amber warning with
  name-choice tips, a PIN input that runs the recover flow inline, and
  an "Anderen Namen wählen" button that returns to the name input

Test guide: added Steps 8 (rate-limit auto-retry) and 9 (name uniqueness)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-04-03 18:36:52 +02:00
parent 7515983a63
commit a43ad6194a
6 changed files with 170 additions and 24 deletions

View File

@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_user_event_name_ci;
CREATE INDEX idx_user_event_name
ON "user"(event_id, display_name);

View File

@@ -0,0 +1,15 @@
-- Deduplicate users with the same name (case-insensitive) per event,
-- keeping the oldest account so no real data is lost.
DELETE FROM "user"
WHERE id NOT IN (
SELECT DISTINCT ON (event_id, LOWER(display_name)) id
FROM "user"
ORDER BY event_id, LOWER(display_name), created_at ASC
);
-- Drop the old non-unique index (replaced below)
DROP INDEX IF EXISTS idx_user_event_name;
-- Unique index enforces one account per name per event (case-insensitive)
CREATE UNIQUE INDEX idx_user_event_name_ci
ON "user" (event_id, LOWER(display_name));

View File

@@ -57,6 +57,14 @@ pub async fn join(
)
.await?;
// Reject if a user with this name (case-insensitive) already exists
if User::name_taken(&state.pool, event.id, display_name).await? {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
// Generate a 4-digit PIN
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
let pin_hash =

View File

@@ -59,7 +59,7 @@ impl User {
display_name: &str,
) -> Result<Vec<Self>, sqlx::Error> {
sqlx::query_as::<_, Self>(
"SELECT * FROM \"user\" WHERE event_id = $1 AND display_name = $2",
"SELECT * FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2)",
)
.bind(event_id)
.bind(display_name)
@@ -67,6 +67,21 @@ impl User {
.await
}
pub async fn name_taken(
pool: &PgPool,
event_id: Uuid,
display_name: &str,
) -> Result<bool, sqlx::Error> {
let row: (bool,) = sqlx::query_as(
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE event_id = $1 AND LOWER(display_name) = LOWER($2))",
)
.bind(event_id)
.bind(display_name)
.fetch_one(pool)
.await?;
Ok(row.0)
}
pub async fn increment_failed_pin(pool: &PgPool, id: Uuid) -> Result<i16, sqlx::Error> {
let row: (i16,) = sqlx::query_as(
"UPDATE \"user\"