mockforge_registry_server/handlers/
cloud_plugins.rs1use 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
27const MAX_USE_CASE_LEN: usize = 2_000;
30
31#[derive(Debug, Deserialize)]
32pub struct BetaInterestRequest {
33 #[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 #[serde(skip_serializing_if = "Option::is_none")]
53 pub use_case: Option<String>,
54}
55
56pub 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 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
94pub 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 let s: String = "🚀".repeat(MAX_USE_CASE_LEN);
164 assert_eq!(sanitize_use_case(Some(&s)).unwrap(), Some(s));
165 }
166}