//! SyncKit app management: create, list, delete, regenerate keys, update links. use axum::{ extract::{Path, State}, response::IntoResponse, Json, }; use crate::{ auth::AuthUser, db::{self, SyncAppId}, error::{AppError, Result}, validation, AppState, }; use super::UpdateAppSlugRequest; use super::{CreateAppRequest, UpdateAppLinkRequest}; /// Create a new sync app and generate its API key. /// /// `POST /api/sync/apps`: Session auth required. /// Returns the app data plus the plaintext API key (shown only once). #[tracing::instrument(skip_all, name = "synckit::create_app")] pub(super) async fn create_app( State(state): State, AuthUser(user): AuthUser, Json(req): Json, ) -> Result { user.check_not_sandbox()?; validation::validate_sync_app_name(&req.name)?; let project_id = parse_and_verify_project(&state, user.id, req.project_id.as_deref()).await?; let item_id = parse_and_verify_item(&state, user.id, req.item_id.as_deref()).await?; let api_key = super::generate_api_key(); let app = db::synckit::create_sync_app( &state.db, user.id, &req.name, &api_key, project_id, item_id, ).await?; Ok((axum::http::StatusCode::CREATED, Json(super::AppWithKey { app, api_key }))) } /// List all sync apps owned by the authenticated user. /// /// `GET /api/sync/apps`: Session auth required. #[tracing::instrument(skip_all, name = "synckit::list_apps")] pub(super) async fn list_apps( State(state): State, AuthUser(user): AuthUser, ) -> Result { let apps = db::synckit::get_sync_apps_by_creator(&state.db, user.id).await?; Ok(Json(apps)) } /// Regenerate the API key for a sync app, invalidating the old one. /// /// `POST /api/sync/apps/{id}/regenerate-key`: Session auth required. #[tracing::instrument(skip_all, name = "synckit::regenerate_app_key")] pub(super) async fn regenerate_app_key( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { let app = db::synckit::get_sync_app_by_id(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } let new_key = super::generate_api_key(); let updated = db::synckit::regenerate_sync_app_key(&state.db, app_id, &new_key).await?; Ok(Json(super::AppWithKey { app: updated, api_key: new_key })) } /// Delete a sync app and all its associated data. /// /// `DELETE /api/sync/apps/{id}`: Session auth required. #[tracing::instrument(skip_all, name = "synckit::delete_app")] pub(super) async fn delete_app( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, ) -> Result { let app = db::synckit::get_sync_app_by_id(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } db::synckit::delete_sync_app(&state.db, app_id).await?; Ok(axum::http::StatusCode::NO_CONTENT) } /// Update the project and/or item link for a sync app. /// /// `PUT /api/sync/apps/{id}/link`: Session auth required. #[tracing::instrument(skip_all, name = "synckit::update_app_link")] pub(super) async fn update_app_link( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, Json(req): Json, ) -> Result { let app = db::synckit::get_sync_app_by_id(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } let project_id = parse_and_verify_project(&state, user.id, req.project_id.as_deref()).await?; let item_id = parse_and_verify_item(&state, user.id, req.item_id.as_deref()).await?; let updated = db::synckit::update_sync_app_link(&state.db, app_id, project_id, item_id).await?; Ok(Json(updated)) } /// Set the OTA slug for a sync app. /// /// `PUT /api/sync/apps/{id}/slug`: Session auth required. #[tracing::instrument(skip_all, name = "synckit::update_app_slug")] pub(super) async fn update_app_slug( State(state): State, AuthUser(user): AuthUser, Path(app_id): Path, Json(req): Json, ) -> Result { let app = db::synckit::get_sync_app_by_id(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != user.id { return Err(AppError::Forbidden); } // Reuse the OTA slug validation crate::routes::ota::validate_slug_public(&req.slug)?; db::ota::set_app_slug(&state.db, app_id, &req.slug).await?; Ok(axum::http::StatusCode::NO_CONTENT) } // ── Link helpers ── /// Parse an optional UUID string and verify the project belongs to the user. async fn parse_and_verify_project( state: &AppState, user_id: db::UserId, raw: Option<&str>, ) -> Result> { let Some(s) = raw.filter(|s| !s.is_empty()) else { return Ok(None); }; let pid: db::ProjectId = s .parse() .map_err(|_| AppError::BadRequest("Invalid project_id".to_string()))?; let project = db::projects::get_project_by_id(&state.db, pid) .await? .ok_or(AppError::BadRequest("Project not found".to_string()))?; if project.user_id != user_id { return Err(AppError::Forbidden); } Ok(Some(pid)) } /// Parse an optional UUID string and verify the item belongs to the user /// (via its parent project). async fn parse_and_verify_item( state: &AppState, user_id: db::UserId, raw: Option<&str>, ) -> Result> { let Some(s) = raw.filter(|s| !s.is_empty()) else { return Ok(None); }; let iid: db::ItemId = s .parse() .map_err(|_| AppError::BadRequest("Invalid item_id".to_string()))?; let item = db::items::get_item_by_id(&state.db, iid) .await? .ok_or(AppError::BadRequest("Item not found".to_string()))?; let project = db::projects::get_project_by_id(&state.db, item.project_id) .await? .ok_or(AppError::BadRequest("Item's project not found".to_string()))?; if project.user_id != user_id { return Err(AppError::Forbidden); } Ok(Some(iid)) }