feat(waitlist): POST /api/waitlist for the nxtgauge.com coming-soon form
All checks were successful
build-and-release / build (catering-services) (push) Successful in 1m28s
build-and-release / build (cron) (push) Successful in 1m51s
build-and-release / build (companies) (push) Successful in 2m7s
build-and-release / build (customers) (push) Successful in 2m54s
build-and-release / build (developers) (push) Successful in 1m39s
build-and-release / build (fitness-trainers) (push) Successful in 1m39s
build-and-release / build (gateway) (push) Successful in 1m31s
build-and-release / build (jobs) (push) Successful in 42s
build-and-release / build (employees) (push) Successful in 2m22s
build-and-release / build (graphic-designers) (push) Successful in 2m8s
build-and-release / build (makeup-artists) (push) Successful in 1m58s
build-and-release / build (payments) (push) Successful in 1m48s
build-and-release / build (job-seekers) (push) Successful in 3m4s
build-and-release / build (social-media-managers) (push) Successful in 2m39s
build-and-release / build (photographers) (push) Successful in 2m42s
backend-integration-tests / ai-credits (push) Successful in 50s
build-and-release / build (tutors) (push) Successful in 3m4s
build-and-release / build (ugc-content-creators) (push) Successful in 2m44s
build-and-release / build (video-editors) (push) Successful in 2m43s
build-and-release / build (users) (push) Successful in 4m38s

The coming-soon page's 'Notify Me' form (nxtgauge-gitops/coming-soon/
index.html) only console.logged the email - nothing was actually
captured. Adds a public (no auth, same trust model as a newsletter
signup) endpoint on the users service:

- New waitlist_signups table (email unique, created_at) - no user_id/
  FK since these are anonymous pre-launch signups, not necessarily
  existing accounts.
- apps/users/src/handlers/waitlist.rs: POST / -> INSERT ... ON
  CONFLICT DO NOTHING (repeat signups from the same email are a no-op,
  not an error), loose email validation (has @ and a dotted domain -
  this is a marketing signup, not an account, so overly strict
  validation just loses real signups to minor typos).
- Routed /api/waitlist through the gateway to the users service,
  alongside the other public routes (packages, kb, etc.)

Applied migration to nxtgauge_test and prod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-08-14 03:46:28 +05:30
parent c2fb7d61f7
commit 77c8330b11
6 changed files with 75 additions and 0 deletions

View file

@ -97,6 +97,7 @@ impl Services {
|| path.starts_with("/api/packages")
|| path.starts_with("/api/support")
|| path.starts_with("/api/reviews")
|| path.starts_with("/api/waitlist")
|| path.starts_with("/api/admin/roles")
|| path.starts_with("/api/admin/users")
|| path.starts_with("/api/admin/verifications")

View file

@ -28,4 +28,5 @@ pub mod external_roles;
pub mod verifications;
pub mod profile;
pub mod role_meta;
pub mod waitlist;
pub mod settings;

View file

@ -0,0 +1,50 @@
//! Public email capture for the nxtgauge.com coming-soon page
//! (nxtgauge-gitops/coming-soon/index.html's "Notify Me" form).
//! Deliberately unauthenticated - anyone can submit an email, same trust
//! model as a newsletter signup form.
use crate::AppState;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};
pub fn router() -> Router<AppState> {
Router::new().route("/", post(join_waitlist))
}
#[derive(Debug, Deserialize)]
struct JoinWaitlistRequest {
email: String,
}
#[derive(Debug, Serialize)]
struct JoinWaitlistResponse {
joined: bool,
}
async fn join_waitlist(
State(state): State<AppState>,
Json(body): Json<JoinWaitlistRequest>,
) -> Result<Json<JoinWaitlistResponse>, (StatusCode, String)> {
let email = body.email.trim().to_lowercase();
// Deliberately loose validation (just "has an @ and something on both
// sides") - this is a pre-launch marketing signup, not an account, and
// being too strict just loses real signups to typos in TLDs etc.
let valid = {
let mut parts = email.splitn(2, '@');
matches!((parts.next(), parts.next()), (Some(local), Some(domain)) if !local.is_empty() && domain.contains('.'))
};
if !valid || email.len() > 255 {
return Err((StatusCode::BAD_REQUEST, "Please enter a valid email address".to_string()));
}
sqlx::query(
"INSERT INTO waitlist_signups (email) VALUES ($1) ON CONFLICT (email) DO NOTHING",
)
.bind(&email)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(JoinWaitlistResponse { joined: true }))
}

View file

@ -119,6 +119,8 @@ async fn main() {
.nest("/api/admin/payment-gateway-config", handlers::payment_gateway::router())
// ── Tracecoin Packages (public) ───────────────────────────────────
.nest("/api/packages", handlers::pricing::public_packages_router())
.nest("/api/waitlist", handlers::waitlist::router())
// ── Tracecoin Packages & Reports (admin) ──────────────────────────
.nest("/api/admin/tracecoin-packages", handlers::pricing::packages_router())
.nest("/api/admin/reports", handlers::pricing::reports_router())

View file

@ -0,0 +1,6 @@
BEGIN;
DROP INDEX IF EXISTS idx_waitlist_signups_created_at;
DROP TABLE IF EXISTS waitlist_signups;
COMMIT;

View file

@ -0,0 +1,15 @@
-- Email capture for the nxtgauge.com coming-soon page's "Notify Me" form
-- (apps/users/src/handlers/waitlist.rs). No user_id/FK - these are
-- anonymous pre-launch signups, not necessarily existing accounts.
BEGIN;
CREATE TABLE IF NOT EXISTS waitlist_signups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_waitlist_signups_created_at ON waitlist_signups(created_at);
COMMIT;