Skip to main content

mockforge_registry_server/handlers/
cloud_workspaces.rs

1//! Workspace CRUD handlers for cloud mode
2
3use 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
40/// List all workspaces for the user's organization
41pub 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/// Detailed workspace shape consumed by WorkspacesPage when a user clicks into a workspace.
61#[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
73/// Get a single workspace by ID (detail view including folders + top-level requests).
74pub 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/// Create a new workspace
121#[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    // Enforce max_projects from plan limits. -1 means unlimited.
143    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/// Update a workspace
201#[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
277/// Delete a workspace
278pub 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}