1use 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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Testimonial {
55 pub author: String,
56 pub company: Option<String>,
57 pub text: String,
58}
59
60#[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#[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#[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
170pub 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(¶ms, "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
195pub 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
208pub 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
228pub async fn get_success_stories(
230 Query(params): Query<HashMap<String, String>>,
231) -> Json<ApiResponse<Vec<SuccessStory>>> {
232 let featured = query_bool(¶ms, "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
246pub 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
275pub 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
288pub 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
308pub 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}