COR-03: fix verification failing on a member's first attempt

The insert into dsec_discord_members deserialised the response into
Vec<DiscordMemberRow>, but without `.returning(...)` supabase-lib-rs never sends
`Prefer: return=representation`, so PostgREST returns a 201 with an empty body.
Parsing that empty body failed and the `?` aborted before add_role and before
any reply — so every member's first click died with "This interaction failed",
while the row was still written (which is why the second attempt worked). Add
`.returning("student_id,discord_id")` so the row comes back and parses.

Also wrap the post-modal verification work so any failure sends the user an
ephemeral "something went wrong" instead of a dead interaction (poise's on_error
has no handle to the modal submission), and set an explicit on_error on
FrameworkOptions that replies ephemerally on command errors and keeps the
default logging. The database-write-first ordering is left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
This commit is contained in:
Clupai8o0 2026-08-30 15:40:01 +10:00
parent 14a2fa8c2f
commit 2b668388d5
2 changed files with 106 additions and 50 deletions

View file

@ -86,12 +86,18 @@ async fn add_dsec_discord_table(
"discord_id": member_id, "discord_id": member_id,
}); });
// `.returning(...)` makes supabase-lib-rs send `Prefer: return=representation`.
// Without it PostgREST defaults a POST to `return=minimal` — a 201 with an
// empty body — and deserialising that empty body into Vec<DiscordMemberRow>
// failed, aborting before the role grant even though the row was written.
// That is why verification failed on every member's first attempt (COR-03).
let _: Vec<DiscordMemberRow> = data let _: Vec<DiscordMemberRow> = data
.state .state
.supabase .supabase
.database() .database()
.insert("dsec_discord_members") .insert("dsec_discord_members")
.values(new_member)? .values(new_member)?
.returning("student_id,discord_id")
.execute() .execute()
.await?; .await?;
@ -220,66 +226,93 @@ async fn handle_verify(
return Ok(()); return Ok(());
}; };
// From here on we hold the modal-submit token, so the slower member fetch // From here on we hold the modal-submit token, so the slower member fetch and
// and database work below is no longer racing the button's ack window. // database work below is no longer racing the button's ack window. Wrap that
// work so any failure (e.g. a database error) still sends the user an
// ephemeral message rather than leaving a dead "This interaction failed"
// interaction — poise's on_error cannot reach this modal submission (COR-03).
let user_id = component_interaction.user.id; let user_id = component_interaction.user.id;
let discord_member = GuildId::member(guild_id, ctx, user_id).await?;
let input_student_id = modal_data.student_id.to_lowercase(); let verify_result: Result<(), Error> = async {
let student_id = input_student_id let discord_member = GuildId::member(guild_id, ctx, user_id).await?;
.strip_prefix("s")
.unwrap_or(&input_student_id);
if cached_name_matches(data, student_id, &modal_data.name) { let input_student_id = modal_data.student_id.to_lowercase();
grant_verified_role( let student_id = input_student_id
ctx, .strip_prefix("s")
data, .unwrap_or(&input_student_id);
&modal_submit,
&discord_member, if cached_name_matches(data, student_id, &modal_data.name) {
student_id, grant_verified_role(
verified_role_id, ctx,
true, data,
) &modal_submit,
.await?; &discord_member,
return Ok(()); student_id,
verified_role_id,
true,
)
.await?;
return Ok(());
}
let Some(student) = fetch_student(data, student_id).await? else {
modal_submit
.create_response(
ctx,
ephemeral_embed(
CreateEmbed::new().title("Student ID not found!").description(
"Your student ID is not found.\nIt takes up to **a week** for your membership to be updated in the database since sign up.\nTry again later.",
),
),
)
.await?;
return Ok(());
};
cache_student(data, student_id, &student.full_name);
if student.full_name.to_lowercase() == modal_data.name.to_lowercase() {
grant_verified_role(
ctx,
data,
&modal_submit,
&discord_member,
student_id,
verified_role_id,
false,
)
.await?;
} else {
modal_submit
.create_response(
ctx,
ephemeral_embed(CreateEmbed::new().title("Name mismatch ❌").description(
"Your student ID is present, however the name does not match. Try again.",
)),
)
.await?;
}
Ok(())
} }
.await;
let Some(student) = fetch_student(data, student_id).await? else { if let Err(err) = verify_result {
modal_submit eprintln!("[verify] verification failed after modal submit: {err}");
// Best-effort ephemeral error so the user does not see the generic
// "This interaction failed" with no way forward.
let _ = modal_submit
.create_response( .create_response(
ctx, ctx,
ephemeral_embed( ephemeral_embed(
CreateEmbed::new().title("Student ID not found!").description( CreateEmbed::new()
"Your student ID is not found.\nIt takes up to **a week** for your membership to be updated in the database since sign up.\nTry again later.", .title("Something went wrong")
), .description(
"A maintainer has been notified. Please try again in a minute.",
),
), ),
) )
.await?; .await;
return Ok(());
};
cache_student(data, student_id, &student.full_name);
if student.full_name.to_lowercase() == modal_data.name.to_lowercase() {
grant_verified_role(
ctx,
data,
&modal_submit,
&discord_member,
student_id,
verified_role_id,
false,
)
.await?;
} else {
modal_submit
.create_response(
ctx,
ephemeral_embed(CreateEmbed::new().title("Name mismatch ❌").description(
"Your student ID is present, however the name does not match. Try again.",
)),
)
.await?;
} }
Ok(()) Ok(())

View file

@ -126,6 +126,28 @@ async fn event_handler(
Ok(()) Ok(())
} }
/// Framework-level error handler. On a slash-command error it replies ephemerally
/// so the user is not left with a dead interaction, and it always runs poise's
/// default logging. `FrameworkOptions::default()` installs this default logger
/// too, but nothing surfaces it (no tracing subscriber, no log shipping — OPS-04),
/// so an explicit handler is set (COR-03).
async fn on_error(error: poise::FrameworkError<'_, Data, Error>) {
if let poise::FrameworkError::Command { ctx, .. } = &error {
let _ = ctx
.send(
poise::CreateReply::default()
.content(
"Something went wrong — a maintainer has been notified. Please try again in a minute.",
)
.ephemeral(true),
)
.await;
}
if let Err(e) = poise::builtins::on_error(error).await {
eprintln!("[on_error] failed while handling a framework error: {e}");
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); // load env dotenv().ok(); // load env
@ -155,6 +177,7 @@ async fn main() {
event_handler: |ctx, event, framework, data| { event_handler: |ctx, event, framework, data| {
Box::pin(event_handler(ctx, event, framework, data)) Box::pin(event_handler(ctx, event, framework, data))
}, },
on_error: |error| Box::pin(on_error(error)),
..Default::default() ..Default::default()
}) })