Skip to main content

mockforge_ui/handlers/
community.rs

1//! Community portal handlers backed by local content storage.
2
3use axum::{
4    extract::{Path, Query, State},
5    http::StatusCode,
6    response::Json,
7};
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use std::{
11    collections::{HashMap, HashSet},
12    path::PathBuf,
13};
14use uuid::Uuid;
15
16use crate::handlers::AdminState;
17use crate::models::ApiResponse;
18
19const DEFAULT_COMMUNITY_CONTENT_FILE: &str = "community/content.json";
20
21/// Showcase project entry
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ShowcaseProject {
24    pub id: String,
25    pub title: String,
26    pub author: String,
27    pub author_avatar: Option<String>,
28    pub description: String,
29    pub category: String,
30    pub tags: Vec<String>,
31    pub featured: bool,
32    pub screenshot: Option<String>,
33    pub demo_url: Option<String>,
34    pub source_url: Option<String>,
35    pub template_id: Option<String>,
36    pub scenario_id: Option<String>,
37    pub stats: ShowcaseStats,
38    pub testimonials: Vec<Testimonial>,
39    pub created_at: chrono::DateTime<Utc>,
40    pub updated_at: chrono::DateTime<Utc>,
41}
42
43/// Showcase statistics
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ShowcaseStats {
46    pub downloads: u64,
47    pub stars: u64,
48    pub forks: u64,
49    pub rating: f64,
50}
51
52/// Testimonial
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Testimonial {
55    pub author: String,
56    pub company: Option<String>,
57    pub text: String,
58}
59
60/// Success story
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct SuccessStory {
63    pub id: String,
64    pub title: String,
65    pub company: String,
66    pub industry: String,
67    pub author: String,
68    pub role: String,
69    pub date: chrono::DateTime<Utc>,
70    pub challenge: String,
71    pub solution: String,
72    pub results: Vec<String>,
73    pub featured: bool,
74}
75
76/// Learning resource
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct LearningResource {
79    pub id: String,
80    pub title: String,
81    pub description: String,
82    pub category: String,
83    pub resource_type: String,
84    pub difficulty: String,
85    pub tags: Vec<String>,
86    pub content_url: Option<String>,
87    pub video_url: Option<String>,
88    pub code_examples: Vec<CodeExample>,
89    pub author: String,
90    pub views: u64,
91    pub rating: f64,
92    pub created_at: chrono::DateTime<Utc>,
93    pub updated_at: chrono::DateTime<Utc>,
94}
95
96/// Code example
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CodeExample {
99    pub title: String,
100    pub language: String,
101    pub code: String,
102    pub description: Option<String>,
103}
104
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106struct CommunityContentStore {
107    #[serde(default)]
108    showcase_projects: Vec<ShowcaseProject>,
109    #[serde(default)]
110    success_stories: Vec<SuccessStory>,
111    #[serde(default)]
112    learning_resources: Vec<LearningResource>,
113}
114
115#[derive(Debug, Deserialize)]
116pub struct SubmitShowcaseRequest {
117    title: String,
118    description: String,
119    category: Option<String>,
120    tags: Option<Vec<String>>,
121    author: Option<String>,
122    author_avatar: Option<String>,
123    screenshot: Option<String>,
124    demo_url: Option<String>,
125    source_url: Option<String>,
126    template_id: Option<String>,
127    scenario_id: Option<String>,
128}
129
130fn content_file_path() -> PathBuf {
131    std::env::var("MOCKFORGE_COMMUNITY_CONTENT_FILE")
132        .map(PathBuf::from)
133        .unwrap_or_else(|_| PathBuf::from(DEFAULT_COMMUNITY_CONTENT_FILE))
134}
135
136async fn load_store() -> CommunityContentStore {
137    let path = content_file_path();
138    let bytes = match tokio::fs::read(&path).await {
139        Ok(bytes) => bytes,
140        Err(_) => return CommunityContentStore::default(),
141    };
142
143    serde_json::from_slice::<CommunityContentStore>(&bytes).unwrap_or_default()
144}
145
146async fn save_store(store: &CommunityContentStore) -> Result<(), String> {
147    let path = content_file_path();
148    if let Some(parent) = path.parent() {
149        tokio::fs::create_dir_all(parent)
150            .await
151            .map_err(|e| format!("Failed to create content directory: {}", e))?;
152    }
153
154    let json = serde_json::to_vec_pretty(store)
155        .map_err(|e| format!("Failed to serialize community content: {}", e))?;
156
157    tokio::fs::write(path, json)
158        .await
159        .map_err(|e| format!("Failed to write community content: {}", e))
160}
161
162fn query_bool(params: &HashMap<String, String>, key: &str) -> Option<bool> {
163    params.get(key).and_then(|value| match value.as_str() {
164        "true" | "1" => Some(true),
165        "false" | "0" => Some(false),
166        _ => None,
167    })
168}
169
170/// Get showcase projects
171pub async fn get_showcase_projects(
172    Query(params): Query<HashMap<String, String>>,
173) -> Json<ApiResponse<Vec<ShowcaseProject>>> {
174    let category = params.get("category").map(String::as_str);
175    let featured = query_bool(&params, "featured");
176    let limit = params.get("limit").and_then(|s| s.parse::<usize>().ok()).unwrap_or(20);
177    let offset = params.get("offset").and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
178
179    let mut projects = load_store().await.showcase_projects;
180
181    if let Some(category) = category {
182        projects.retain(|p| p.category == category);
183    }
184
185    if let Some(featured) = featured {
186        projects.retain(|p| p.featured == featured);
187    }
188
189    projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
190    let projects = projects.into_iter().skip(offset).take(limit).collect();
191
192    Json(ApiResponse::success(projects))
193}
194
195/// Get showcase project by ID
196pub async fn get_showcase_project(
197    Path(project_id): Path<String>,
198) -> Json<ApiResponse<ShowcaseProject>> {
199    let store = load_store().await;
200    let project = store.showcase_projects.into_iter().find(|p| p.id == project_id);
201
202    match project {
203        Some(project) => Json(ApiResponse::success(project)),
204        None => Json(ApiResponse::error(format!("Showcase project not found: {}", project_id))),
205    }
206}
207
208/// Get showcase categories
209pub async fn get_showcase_categories() -> Json<ApiResponse<Vec<String>>> {
210    let store = load_store().await;
211    let mut categories: HashSet<String> = store
212        .showcase_projects
213        .iter()
214        .map(|p| p.category.clone())
215        .filter(|c| !c.is_empty())
216        .collect();
217
218    if categories.is_empty() {
219        categories.insert("other".to_string());
220    }
221
222    let mut categories: Vec<String> = categories.into_iter().collect();
223    categories.sort();
224
225    Json(ApiResponse::success(categories))
226}
227
228/// Get success stories
229pub async fn get_success_stories(
230    Query(params): Query<HashMap<String, String>>,
231) -> Json<ApiResponse<Vec<SuccessStory>>> {
232    let featured = query_bool(&params, "featured");
233    let limit = params.get("limit").and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
234
235    let mut stories = load_store().await.success_stories;
236    if let Some(featured) = featured {
237        stories.retain(|story| story.featured == featured);
238    }
239
240    stories.sort_by(|a, b| b.date.cmp(&a.date));
241    stories.truncate(limit);
242
243    Json(ApiResponse::success(stories))
244}
245
246/// Get learning resources
247pub async fn get_learning_resources(
248    Query(params): Query<HashMap<String, String>>,
249) -> Json<ApiResponse<Vec<LearningResource>>> {
250    let category = params.get("category").map(String::as_str);
251    let resource_type = params.get("type").map(String::as_str);
252    let difficulty = params.get("difficulty").map(String::as_str);
253    let limit = params.get("limit").and_then(|s| s.parse::<usize>().ok()).unwrap_or(20);
254
255    let mut resources = load_store().await.learning_resources;
256
257    if let Some(category) = category {
258        resources.retain(|r| r.category == category);
259    }
260
261    if let Some(resource_type) = resource_type {
262        resources.retain(|r| r.resource_type == resource_type);
263    }
264
265    if let Some(difficulty) = difficulty {
266        resources.retain(|r| r.difficulty == difficulty);
267    }
268
269    resources.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
270    resources.truncate(limit);
271
272    Json(ApiResponse::success(resources))
273}
274
275/// Get learning resource by ID
276pub async fn get_learning_resource(
277    Path(resource_id): Path<String>,
278) -> Json<ApiResponse<LearningResource>> {
279    let store = load_store().await;
280    let resource = store.learning_resources.into_iter().find(|resource| resource.id == resource_id);
281
282    match resource {
283        Some(resource) => Json(ApiResponse::success(resource)),
284        None => Json(ApiResponse::error(format!("Learning resource not found: {}", resource_id))),
285    }
286}
287
288/// Get learning resource categories
289pub async fn get_learning_categories() -> Json<ApiResponse<Vec<String>>> {
290    let store = load_store().await;
291    let mut categories: HashSet<String> = store
292        .learning_resources
293        .iter()
294        .map(|r| r.category.clone())
295        .filter(|c| !c.is_empty())
296        .collect();
297
298    if categories.is_empty() {
299        categories.insert("getting-started".to_string());
300    }
301
302    let mut categories: Vec<String> = categories.into_iter().collect();
303    categories.sort();
304
305    Json(ApiResponse::success(categories))
306}
307
308/// Submit a project for showcase
309pub async fn submit_showcase_project(
310    State(_state): State<AdminState>,
311    Json(payload): Json<SubmitShowcaseRequest>,
312) -> Result<Json<ApiResponse<String>>, StatusCode> {
313    if payload.title.trim().is_empty() || payload.description.trim().is_empty() {
314        return Ok(Json(ApiResponse::error("title and description are required".to_string())));
315    }
316
317    let mut store = load_store().await;
318
319    let now = Utc::now();
320    let project = ShowcaseProject {
321        id: Uuid::new_v4().to_string(),
322        title: payload.title,
323        author: payload.author.unwrap_or_else(|| "anonymous".to_string()),
324        author_avatar: payload.author_avatar,
325        description: payload.description,
326        category: payload.category.unwrap_or_else(|| "other".to_string()),
327        tags: payload.tags.unwrap_or_default(),
328        featured: false,
329        screenshot: payload.screenshot,
330        demo_url: payload.demo_url,
331        source_url: payload.source_url,
332        template_id: payload.template_id,
333        scenario_id: payload.scenario_id,
334        stats: ShowcaseStats {
335            downloads: 0,
336            stars: 0,
337            forks: 0,
338            rating: 0.0,
339        },
340        testimonials: Vec::new(),
341        created_at: now,
342        updated_at: now,
343    };
344
345    store.showcase_projects.push(project);
346
347    if let Err(e) = save_store(&store).await {
348        tracing::error!("Failed to persist showcase project: {}", e);
349        return Err(StatusCode::INTERNAL_SERVER_ERROR);
350    }
351
352    Ok(Json(ApiResponse::success("Project submitted successfully".to_string())))
353}