Skip to content

Commit c276fe8

Browse files
committed
feat: require email for RFQ access requests, make WhatsApp optional
- Add `Serialize` derive and `AccessRequestPayload` struct to properly serialize access request payloads with `skip_serializing_if` to omit null fields, since the venue validates these as optional strings - Extract `filled()` helper to trim and filter blank values consistently across the codebase - Change validation from `require_contact()` to `require_email()` since email is the required channel for Textile to reply, making WhatsApp a bonus contact method - Update error message to clarify that email is required for replies while WhatsApp is optional - Replace manual `json!()` macro call with structured `AccessRequestPayload` serialization to prevent null fields in the request body - Add test assertion verifying no null fields are sent in the request body - Expand `request_access_needs_contact` test to verify email is mandatory even when WhatsApp is provided - Update UI help text to explain email is required for review replies and WhatsApp is optional - Add "(optional)" label to WhatsApp field in the form - Disable access request button when email field is empty
1 parent 6e825d4 commit c276fe8

6 files changed

Lines changed: 64 additions & 34 deletions

File tree

.textile-monorepo-source

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
a452cbd58b24d83c3e74d64281548bade5717070
1+
4c915b9a9f9bb3552918467b980b2c5a60796103

.textile-stitch-release-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.209
1+
0.1.210

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stitch-bot"
3-
version = "0.1.209"
3+
version = "0.1.210"
44
edition = "2021"
55
description = "Stitch — Textile filler-network operator bot; market-makes the filler order book with signed UniswapX limit orders."
66
license = "AGPL-3.0-or-later"

src/panel/http/access.rs

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
use axum::extract::{Path as UrlPath, State};
1010
use axum::response::{IntoResponse, Response};
1111
use axum::Json;
12-
use serde::Deserialize;
12+
use serde::{Deserialize, Serialize};
1313
use serde_json::json;
1414

1515
use super::enroll::{
@@ -53,14 +53,35 @@ struct AccessStatusResponse {
5353
corridor_pairs: Vec<EnrollCorridorPair>,
5454
}
5555

56-
fn require_contact(email: Option<&str>, whatsapp: Option<&str>) -> Result<(), ApiError> {
57-
let has_email = email.map(str::trim).is_some_and(|s| !s.is_empty());
58-
let has_wa = whatsapp.map(str::trim).is_some_and(|s| !s.is_empty());
59-
if has_email || has_wa {
56+
/// Body for POST /v2/maker/access-request. The form leaves most of these
57+
/// blank, and blank has to mean absent: the venue validates them as optional
58+
/// strings, so a `null` reads as the wrong type and 400s the whole request.
59+
#[derive(Serialize)]
60+
#[serde(rename_all = "camelCase")]
61+
struct AccessRequestPayload<'a> {
62+
#[serde(skip_serializing_if = "Option::is_none")]
63+
contact_email: Option<&'a str>,
64+
#[serde(skip_serializing_if = "Option::is_none")]
65+
contact_whatsapp: Option<&'a str>,
66+
#[serde(skip_serializing_if = "Option::is_none")]
67+
note: Option<&'a str>,
68+
#[serde(skip_serializing_if = "Option::is_none")]
69+
corridor: Option<&'a str>,
70+
}
71+
72+
/// Trimmed value, or None when it is missing or blank.
73+
fn filled(value: Option<&str>) -> Option<&str> {
74+
value.map(str::trim).filter(|s| !s.is_empty())
75+
}
76+
77+
/// Email is the channel Textile answers a review on, so it is the required
78+
/// one. WhatsApp is a bonus number for them to ping.
79+
fn require_email(email: Option<&str>) -> Result<(), ApiError> {
80+
if filled(email).is_some() {
6081
return Ok(());
6182
}
6283
Err(ApiError::bad_request(
63-
"add an email address or a WhatsApp number so Textile can contact you",
84+
"add an email address so Textile can reply to your access request — WhatsApp is optional",
6485
))
6586
}
6687

@@ -87,10 +108,7 @@ pub async fn request_access(
87108
let current_toml = read_toml(&path)?;
88109
let cfg = Config::from_toml(&current_toml)
89110
.map_err(|e| ApiError::bad_request(format!("this config isn't valid: {e:#}")))?;
90-
require_contact(
91-
body.contact_email.as_deref(),
92-
body.contact_whatsapp.as_deref(),
93-
)?;
111+
require_email(body.contact_email.as_deref())?;
94112

95113
let dir = path.parent().ok_or_else(|| {
96114
ApiError::internal(&anyhow::anyhow!(
@@ -106,16 +124,18 @@ pub async fn request_access(
106124
let response = venue_client()?
107125
.post(&venue)
108126
.bearer_auth(&api_key)
109-
.json(&json!({
110-
"contactEmail": body.contact_email.as_deref().map(str::trim).filter(|s| !s.is_empty()),
111-
"contactWhatsapp": body.contact_whatsapp.as_deref().map(str::trim).filter(|s| !s.is_empty()),
112-
"note": body.note.as_deref().map(str::trim).filter(|s| !s.is_empty()),
113-
"corridor": corridor,
114-
}))
127+
.json(&AccessRequestPayload {
128+
contact_email: filled(body.contact_email.as_deref()),
129+
contact_whatsapp: filled(body.contact_whatsapp.as_deref()),
130+
note: filled(body.note.as_deref()),
131+
corridor: filled(corridor.as_deref()),
132+
})
115133
.send()
116134
.await
117135
.map_err(|e| {
118-
ApiError::bad_request(format!("could not reach Textile access request at {venue}: {e}"))
136+
ApiError::bad_request(format!(
137+
"could not reach Textile access request at {venue}: {e}"
138+
))
119139
})?;
120140
let status = response.status();
121141
let text = response.text().await.map_err(|e| {
@@ -339,6 +359,16 @@ mod tests {
339359
body["contactEmail"].as_str().is_some()
340360
|| body["contactWhatsapp"].as_str().is_some()
341361
);
362+
// The venue validates these as optional strings, so a
363+
// blank field must be absent rather than null.
364+
assert!(
365+
!body
366+
.as_object()
367+
.expect("object body")
368+
.values()
369+
.any(Value::is_null),
370+
"sent a null field: {body}"
371+
);
342372
Json(json!({ "accessStatus": "PENDING", "requestId": "clreq1" }))
343373
},
344374
),
@@ -395,19 +425,18 @@ mod tests {
395425
}
396426

397427
#[tokio::test]
398-
async fn request_access_needs_contact() {
428+
async fn request_access_needs_an_email_and_whatsapp_stays_optional() {
399429
let h = harness("rfq-access-contact");
400430
seed(&h, "bot-a");
401431
unlock_rfq_panel(&h, "bot-a");
402432
setup::write_rfq_api_key(h.root.join("bot-a"), "tx_live_enroll_secret").unwrap();
403-
let (status, body) = h
404-
.post_json("/api/bots/bot-a/rfq/access-request", json!({}))
405-
.await;
406-
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
407-
assert!(
408-
body.contains("email") || body.contains("WhatsApp"),
409-
"{body}"
410-
);
433+
for payload in [json!({}), json!({ "contactWhatsapp": "+15551234567" })] {
434+
let (status, body) = h
435+
.post_json("/api/bots/bot-a/rfq/access-request", payload)
436+
.await;
437+
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
438+
assert!(body.contains("email"), "{body}");
439+
}
411440
}
412441

413442
#[tokio::test]

web/src/components/SettingsForm.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -816,8 +816,9 @@ function RfqCard({
816816
<div className="space-y-3 rounded-lg border border-line-soft p-3">
817817
<p className="text-sm font-bold">Request access</p>
818818
<p className="text-xs text-faint">
819-
Textile needs a way to reach you. Email or WhatsApp — one is
820-
enough.
819+
Textile replies about the review by email, so that one is
820+
required. WhatsApp is optional — add it if you would rather they
821+
ping you there.
821822
</p>
822823
<Field label="Email">
823824
<Input
@@ -829,7 +830,7 @@ function RfqCard({
829830
onChange={(e) => setContactEmail(e.target.value)}
830831
/>
831832
</Field>
832-
<Field label="WhatsApp">
833+
<Field label="WhatsApp (optional)">
833834
<Input
834835
type="tel"
835836
value={contactWhatsapp}
@@ -843,7 +844,7 @@ function RfqCard({
843844
<Button
844845
variant="primary"
845846
busy={requesting}
846-
disabled={!editable}
847+
disabled={!editable || !contactEmail.trim()}
847848
onClick={() => void requestAccess()}
848849
>
849850
Request access

0 commit comments

Comments
 (0)