//! Create, update, delete project skills. use crate::AppService; use crate::error::AppError; use super::info::SkillResponse; use chrono::Utc; use models::projects::project_skill::{Column as C, Entity as SkillEntity}; use models::ActiveModelTrait; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateSkillRequest { pub slug: String, pub name: Option, pub description: Option, pub content: String, pub metadata: Option, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateSkillRequest { pub name: Option, pub description: Option, pub content: Option, pub metadata: Option, pub enabled: Option, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct DeleteSkillResponse { pub deleted: bool, pub slug: String, } fn validate_slug(slug: &str) -> Result<(), AppError> { if slug.is_empty() || slug.len() > 255 { return Err(AppError::BadRequest("Invalid slug".to_string())); } if !slug .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { return Err(AppError::BadRequest( "Slug must contain only ASCII letters, numbers, hyphens, and underscores".to_string(), )); } Ok(()) } impl AppService { /// Add a skill manually to a project. pub async fn skill_create( &self, project_uuid: String, request: CreateSkillRequest, ctx: &Session, ) -> Result { validate_slug(&request.slug)?; let project_id = Uuid::parse_str(&project_uuid) .map_err(|_| AppError::BadRequest("Invalid project UUID".to_string()))?; let user_id = ctx .user() .ok_or_else(|| AppError::Unauthorized)?; // Check for duplicate slug within project let exists = SkillEntity::find() .filter(C::ProjectUuid.eq(project_id)) .filter(C::Slug.eq(&request.slug)) .one(&self.db) .await?; if exists.is_some() { return Err(AppError::Conflict(format!( "Skill '{}' already exists in this project", request.slug ))); } let now = Utc::now(); let metadata = request.metadata.unwrap_or(serde_json::Value::Object(Default::default())); let name = request.name.unwrap_or_else(|| request.slug.clone()); let active = models::projects::project_skill::ActiveModel { id: Set(0), // auto-increment project_uuid: Set(project_id), slug: Set(request.slug), name: Set(name), description: Set(request.description), source: Set("manual".to_string()), repo_id: Set(None), commit_sha: Set(None), blob_hash: Set(None), content: Set(request.content), metadata: Set(metadata), enabled: Set(true), created_by: Set(Some(user_id)), created_at: Set(now), updated_at: Set(now), }; let inserted = active.insert(&self.db).await?; Ok(SkillResponse::from(inserted)) } /// Update an existing skill. pub async fn skill_update( &self, project_uuid: String, slug: String, request: UpdateSkillRequest, _ctx: &Session, ) -> Result { let project_id = Uuid::parse_str(&project_uuid) .map_err(|_| AppError::BadRequest("Invalid project UUID".to_string()))?; let skill = SkillEntity::find() .filter(C::ProjectUuid.eq(project_id)) .filter(C::Slug.eq(&slug)) .one(&self.db) .await? .ok_or_else(|| AppError::NotFound("Skill not found".to_string()))?; let mut active: models::projects::project_skill::ActiveModel = skill.into(); if let Some(name) = request.name { active.name = Set(name); } if let Some(description) = request.description { active.description = Set(Some(description)); } if let Some(content) = request.content { active.content = Set(content); } if let Some(metadata) = request.metadata { active.metadata = Set(metadata); } if let Some(enabled) = request.enabled { active.enabled = Set(enabled); } active.updated_at = Set(Utc::now()); let updated = active.update(&self.db).await?; Ok(SkillResponse::from(updated)) } /// Delete a skill from a project. pub async fn skill_delete( &self, project_uuid: String, slug: String, _ctx: &Session, ) -> Result { let project_id = Uuid::parse_str(&project_uuid) .map_err(|_| AppError::BadRequest("Invalid project UUID".to_string()))?; let skill = SkillEntity::find() .filter(C::ProjectUuid.eq(project_id)) .filter(C::Slug.eq(&slug)) .one(&self.db) .await? .ok_or_else(|| AppError::NotFound("Skill not found".to_string()))?; let deleted = SkillEntity::delete_by_id(skill.id).exec(&self.db).await?; Ok(DeleteSkillResponse { deleted: deleted.rows_affected > 0, slug, }) } }