Skip to main content

mockforge_registry_server/handlers/
cloud_plugins.rs

1//! Cloud Plugins beta interest endpoints (Phase 0 demand validation).
2//!
3//! These endpoints back the "Request beta access" CTA on the cloud
4//! `/plugin-registry` page. They are deliberately tiny: a single UPSERT
5//! and a single point-read. Aggregate analysis for the go/no-go review
6//! is done off-line via SQL against `cloud_plugin_beta_interest`.
7//!
8//! Routes:
9//!   POST /api/v1/cloud-plugins/beta-interest
10//!   GET  /api/v1/cloud-plugins/beta-interest/me
11//!
12//! The endpoint is NOT org-scoped on purpose: registering interest is a
13//! per-user action, not a per-org one. We still snapshot the user's
14//! current org_id + plan so the go/no-go review can segment by tier.
15
16use axum::{extract::State, http::HeaderMap, Json};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::{
21    error::{ApiError, ApiResult},
22    middleware::{resolve_org_context, AuthUser},
23    models::CloudPluginBetaInterest,
24    AppState,
25};
26
27/// Free-text caps. Trimmed and length-limited server-side so a runaway
28/// client can't dump unlimited text into the table.
29const MAX_USE_CASE_LEN: usize = 2_000;
30
31#[derive(Debug, Deserialize)]
32pub struct BetaInterestRequest {
33    /// Optional "what would you build with cloud plugins?" answer.
34    #[serde(default)]
35    pub use_case: Option<String>,
36}
37
38#[derive(Debug, Serialize)]
39pub struct BetaInterestResponse {
40    pub id: String,
41    pub created_at: DateTime<Utc>,
42    pub updated_at: DateTime<Utc>,
43}
44
45#[derive(Debug, Serialize)]
46pub struct BetaInterestStatusResponse {
47    pub signed_up: bool,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub created_at: Option<DateTime<Utc>>,
50    /// Echo back the user's last submitted use case so the form can
51    /// pre-populate when they revisit.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub use_case: Option<String>,
54}
55
56/// `POST /api/v1/cloud-plugins/beta-interest`
57///
58/// UPSERT — second submission updates `use_case` instead of erroring.
59pub async fn submit_interest(
60    State(state): State<AppState>,
61    AuthUser(user_id): AuthUser,
62    headers: HeaderMap,
63    Json(request): Json<BetaInterestRequest>,
64) -> ApiResult<Json<BetaInterestResponse>> {
65    let use_case = sanitize_use_case(request.use_case.as_deref())?;
66
67    // Best-effort org context — we don't fail the signup if the user has
68    // no current org, we just store NULL.
69    let (org_id, plan_at_signup) = match resolve_org_context(&state, user_id, &headers, None).await
70    {
71        Ok(ctx) => (Some(ctx.org_id), Some(ctx.org.plan)),
72        Err(_) => (None, None),
73    };
74
75    let row = CloudPluginBetaInterest::upsert(
76        state.db.pool(),
77        crate::models::cloud_plugin_beta_interest::UpsertCloudPluginBetaInterest {
78            user_id,
79            org_id,
80            use_case: use_case.as_deref(),
81            plan_at_signup: plan_at_signup.as_deref(),
82        },
83    )
84    .await
85    .map_err(ApiError::Database)?;
86
87    Ok(Json(BetaInterestResponse {
88        id: row.id.to_string(),
89        created_at: row.created_at,
90        updated_at: row.updated_at,
91    }))
92}
93
94/// `GET /api/v1/cloud-plugins/beta-interest/me`
95pub async fn get_my_interest(
96    State(state): State<AppState>,
97    AuthUser(user_id): AuthUser,
98) -> ApiResult<Json<BetaInterestStatusResponse>> {
99    let existing = CloudPluginBetaInterest::find_by_user(state.db.pool(), user_id)
100        .await
101        .map_err(ApiError::Database)?;
102
103    Ok(Json(match existing {
104        Some(row) => BetaInterestStatusResponse {
105            signed_up: true,
106            created_at: Some(row.created_at),
107            use_case: row.use_case,
108        },
109        None => BetaInterestStatusResponse {
110            signed_up: false,
111            created_at: None,
112            use_case: None,
113        },
114    }))
115}
116
117fn sanitize_use_case(raw: Option<&str>) -> ApiResult<Option<String>> {
118    let Some(text) = raw else {
119        return Ok(None);
120    };
121    let trimmed = text.trim();
122    if trimmed.is_empty() {
123        return Ok(None);
124    }
125    if trimmed.chars().count() > MAX_USE_CASE_LEN {
126        return Err(ApiError::InvalidRequest(format!(
127            "use_case must be {} characters or fewer",
128            MAX_USE_CASE_LEN
129        )));
130    }
131    Ok(Some(trimmed.to_string()))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn sanitize_trims_and_drops_empty() {
140        assert_eq!(sanitize_use_case(None).unwrap(), None);
141        assert_eq!(sanitize_use_case(Some("")).unwrap(), None);
142        assert_eq!(sanitize_use_case(Some("   ")).unwrap(), None);
143        assert_eq!(sanitize_use_case(Some("  hello  ")).unwrap(), Some("hello".to_string()));
144    }
145
146    #[test]
147    fn sanitize_rejects_too_long() {
148        let too_long: String = "x".repeat(MAX_USE_CASE_LEN + 1);
149        let err = sanitize_use_case(Some(&too_long)).unwrap_err();
150        assert!(matches!(err, ApiError::InvalidRequest(_)));
151    }
152
153    #[test]
154    fn sanitize_accepts_max_length() {
155        let exact: String = "x".repeat(MAX_USE_CASE_LEN);
156        assert_eq!(sanitize_use_case(Some(&exact)).unwrap(), Some(exact));
157    }
158
159    #[test]
160    fn sanitize_counts_chars_not_bytes() {
161        // Multi-byte UTF-8 char — 4 bytes, 1 grapheme. We count chars to
162        // be lenient with non-ASCII input.
163        let s: String = "🚀".repeat(MAX_USE_CASE_LEN);
164        assert_eq!(sanitize_use_case(Some(&s)).unwrap(), Some(s));
165    }
166}