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:
4
backend/migrations/007_user_name_unique.down.sql
Normal file
4
backend/migrations/007_user_name_unique.down.sql
Normal 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);
|
||||
15
backend/migrations/007_user_name_unique.up.sql
Normal file
15
backend/migrations/007_user_name_unique.up.sql
Normal 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));
|
||||
@@ -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 =
|
||||
|
||||
@@ -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\"
|
||||
|
||||
Reference in New Issue
Block a user