mockforge_registry_server/handlers/
cloud_workspaces.rs1use axum::{
4 extract::{Path, State},
5 http::HeaderMap,
6 Json,
7};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11use crate::{
12 error::{ApiError, ApiResult},
13 middleware::{resolve_org_context, AuthUser},
14 models::{
15 cloud_workspace::WorkspaceSummaryResponse,
16 workspace_folder::{FolderSummaryResponse, WorkspaceFolder},
17 workspace_request::{RequestSummaryResponse, WorkspaceRequest},
18 AuditEventType, FeatureType,
19 },
20 AppState,
21};
22
23async fn summarize_workspace(
24 pool: &sqlx::PgPool,
25 workspace: &crate::models::CloudWorkspace,
26) -> ApiResult<WorkspaceSummaryResponse> {
27 let folder_count: i64 =
28 sqlx::query_scalar("SELECT COUNT(*) FROM workspace_folders WHERE workspace_id = $1")
29 .bind(workspace.id)
30 .fetch_one(pool)
31 .await?;
32 let request_count = WorkspaceRequest::count_in_workspace(pool, workspace.id).await?;
33
34 let mut summary = workspace.to_summary();
35 summary.folder_count = folder_count;
36 summary.request_count = request_count;
37 Ok(summary)
38}
39
40pub async fn list_workspaces(
42 State(state): State<AppState>,
43 AuthUser(user_id): AuthUser,
44 headers: HeaderMap,
45) -> ApiResult<Json<Vec<WorkspaceSummaryResponse>>> {
46 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
47 .await
48 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
49
50 let workspaces = state.store.list_cloud_workspaces_by_org(org_ctx.org_id).await?;
51
52 let mut summaries: Vec<WorkspaceSummaryResponse> = Vec::with_capacity(workspaces.len());
53 for ws in &workspaces {
54 summaries.push(summarize_workspace(state.db.pool(), ws).await?);
55 }
56
57 Ok(Json(summaries))
58}
59
60#[derive(Debug, Serialize)]
62pub struct WorkspaceDetailResponse {
63 pub summary: WorkspaceSummaryResponse,
64 pub folders: Vec<FolderSummaryResponse>,
65 pub requests: Vec<RequestSummaryResponse>,
66}
67
68#[derive(Debug, Serialize)]
69pub struct WorkspaceResponseEnvelope {
70 pub workspace: WorkspaceDetailResponse,
71}
72
73pub async fn get_workspace(
75 State(state): State<AppState>,
76 AuthUser(user_id): AuthUser,
77 headers: HeaderMap,
78 Path(id): Path<Uuid>,
79) -> ApiResult<Json<WorkspaceResponseEnvelope>> {
80 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
81 .await
82 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
83
84 let workspace = state
85 .store
86 .find_cloud_workspace_by_id(id)
87 .await?
88 .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
89
90 if workspace.org_id != org_ctx.org_id {
91 return Err(ApiError::InvalidRequest(
92 "Workspace does not belong to this organization".to_string(),
93 ));
94 }
95
96 let pool = state.db.pool();
97 let summary = summarize_workspace(pool, &workspace).await?;
98
99 let folders = WorkspaceFolder::list_by_workspace(pool, id).await?;
100 let mut folder_summaries: Vec<FolderSummaryResponse> = Vec::with_capacity(folders.len());
101 for f in &folders {
102 folder_summaries.push(f.to_summary_response(pool).await?);
103 }
104
105 let top_level_requests = WorkspaceRequest::list_by_workspace(pool, id)
106 .await?
107 .into_iter()
108 .map(|r| r.to_summary())
109 .collect::<Vec<_>>();
110
111 Ok(Json(WorkspaceResponseEnvelope {
112 workspace: WorkspaceDetailResponse {
113 summary,
114 folders: folder_summaries,
115 requests: top_level_requests,
116 },
117 }))
118}
119
120#[derive(Debug, Deserialize)]
122pub struct CreateWorkspaceRequest {
123 pub name: String,
124 #[serde(default)]
125 pub description: String,
126}
127
128pub async fn create_workspace(
129 State(state): State<AppState>,
130 AuthUser(user_id): AuthUser,
131 headers: HeaderMap,
132 Json(request): Json<CreateWorkspaceRequest>,
133) -> ApiResult<Json<WorkspaceSummaryResponse>> {
134 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
135 .await
136 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
137
138 if request.name.trim().is_empty() {
139 return Err(ApiError::InvalidRequest("Workspace name is required".to_string()));
140 }
141
142 let max_projects = org_ctx
144 .org
145 .limits_json
146 .get("max_projects")
147 .and_then(|v| v.as_i64())
148 .unwrap_or(1);
149 if max_projects >= 0 {
150 let existing = state.store.list_cloud_workspaces_by_org(org_ctx.org_id).await?;
151 if existing.len() as i64 >= max_projects {
152 return Err(ApiError::InvalidRequest(format!(
153 "Workspace limit reached. Your plan allows {} workspace(s). Upgrade to create more.",
154 max_projects
155 )));
156 }
157 }
158
159 let workspace = state
160 .store
161 .create_cloud_workspace(org_ctx.org_id, user_id, request.name.trim(), &request.description)
162 .await?;
163
164 state
165 .store
166 .record_feature_usage(
167 org_ctx.org_id,
168 Some(user_id),
169 FeatureType::WorkspaceCreate,
170 Some(serde_json::json!({
171 "workspace_id": workspace.id,
172 "name": workspace.name,
173 })),
174 )
175 .await;
176
177 let ip_address = headers
178 .get("X-Forwarded-For")
179 .or_else(|| headers.get("X-Real-IP"))
180 .and_then(|h| h.to_str().ok())
181 .map(|s| s.split(',').next().unwrap_or(s).trim());
182 let user_agent = headers.get("User-Agent").and_then(|h| h.to_str().ok());
183
184 state
185 .store
186 .record_audit_event(
187 org_ctx.org_id,
188 Some(user_id),
189 AuditEventType::WorkspaceCreated,
190 format!("Workspace '{}' created", workspace.name),
191 Some(serde_json::json!({ "workspace_id": workspace.id, "name": workspace.name })),
192 ip_address,
193 user_agent,
194 )
195 .await;
196
197 Ok(Json(workspace.to_summary()))
198}
199
200#[derive(Debug, Deserialize)]
202pub struct UpdateWorkspaceRequest {
203 pub name: Option<String>,
204 pub description: Option<String>,
205 pub is_active: Option<bool>,
206 pub settings: Option<serde_json::Value>,
207}
208
209pub async fn update_workspace(
210 State(state): State<AppState>,
211 AuthUser(user_id): AuthUser,
212 headers: HeaderMap,
213 Path(id): Path<Uuid>,
214 Json(request): Json<UpdateWorkspaceRequest>,
215) -> ApiResult<Json<WorkspaceSummaryResponse>> {
216 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
217 .await
218 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
219
220 let existing = state
221 .store
222 .find_cloud_workspace_by_id(id)
223 .await?
224 .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
225
226 if existing.org_id != org_ctx.org_id {
227 return Err(ApiError::InvalidRequest(
228 "Workspace does not belong to this organization".to_string(),
229 ));
230 }
231
232 let workspace = state
233 .store
234 .update_cloud_workspace(
235 id,
236 request.name.as_deref(),
237 request.description.as_deref(),
238 request.is_active,
239 request.settings.as_ref(),
240 )
241 .await?
242 .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
243
244 state
245 .store
246 .record_feature_usage(
247 org_ctx.org_id,
248 Some(user_id),
249 FeatureType::WorkspaceUpdate,
250 Some(serde_json::json!({ "workspace_id": workspace.id })),
251 )
252 .await;
253
254 let ip_address = headers
255 .get("X-Forwarded-For")
256 .or_else(|| headers.get("X-Real-IP"))
257 .and_then(|h| h.to_str().ok())
258 .map(|s| s.split(',').next().unwrap_or(s).trim());
259 let user_agent = headers.get("User-Agent").and_then(|h| h.to_str().ok());
260
261 state
262 .store
263 .record_audit_event(
264 org_ctx.org_id,
265 Some(user_id),
266 AuditEventType::WorkspaceUpdated,
267 format!("Workspace '{}' updated", workspace.name),
268 Some(serde_json::json!({ "workspace_id": workspace.id })),
269 ip_address,
270 user_agent,
271 )
272 .await;
273
274 Ok(Json(workspace.to_summary()))
275}
276
277pub async fn delete_workspace(
279 State(state): State<AppState>,
280 AuthUser(user_id): AuthUser,
281 headers: HeaderMap,
282 Path(id): Path<Uuid>,
283) -> ApiResult<Json<serde_json::Value>> {
284 let org_ctx = resolve_org_context(&state, user_id, &headers, None)
285 .await
286 .map_err(|_| ApiError::InvalidRequest("Organization not found".to_string()))?;
287
288 let workspace = state
289 .store
290 .find_cloud_workspace_by_id(id)
291 .await?
292 .ok_or_else(|| ApiError::InvalidRequest("Workspace not found".to_string()))?;
293
294 if workspace.org_id != org_ctx.org_id {
295 return Err(ApiError::InvalidRequest(
296 "Workspace does not belong to this organization".to_string(),
297 ));
298 }
299
300 let ip_address = headers
301 .get("X-Forwarded-For")
302 .or_else(|| headers.get("X-Real-IP"))
303 .and_then(|h| h.to_str().ok())
304 .map(|s| s.split(',').next().unwrap_or(s).trim());
305 let user_agent = headers.get("User-Agent").and_then(|h| h.to_str().ok());
306
307 state
308 .store
309 .record_audit_event(
310 org_ctx.org_id,
311 Some(user_id),
312 AuditEventType::WorkspaceDeleted,
313 format!("Workspace '{}' deleted", workspace.name),
314 Some(serde_json::json!({ "workspace_id": workspace.id, "name": workspace.name })),
315 ip_address,
316 user_agent,
317 )
318 .await;
319
320 state
321 .store
322 .record_feature_usage(
323 org_ctx.org_id,
324 Some(user_id),
325 FeatureType::WorkspaceDelete,
326 Some(serde_json::json!({ "workspace_id": workspace.id })),
327 )
328 .await;
329
330 state.store.delete_cloud_workspace(id).await?;
331
332 Ok(Json(serde_json::json!({ "success": true })))
333}