use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; use crate::models::wiki::WikiPage; use crate::service::RepoService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateWikiPageParams { pub title: String, pub content: String, } #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateWikiPageParams { pub title: Option, pub content: Option, pub commit_message: Option, } impl RepoService { /// List all wiki pages in a repository. pub async fn wiki_list_pages( &self, ctx: &Session, wk_name: &str, repo_name: &str, search: Option, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let (limit, offset) = clamp_limit_offset(limit, offset); if let Some(query) = search { let pattern = format!("%{}%", query); sqlx::query_as::<_, WikiPage>( "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ created_at, updated_at, deleted_at \ FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \ AND (title ILIKE $2 OR content ILIKE $2) \ ORDER BY updated_at DESC LIMIT $3 OFFSET $4", ) .bind(repo.id) .bind(&pattern) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } else { sqlx::query_as::<_, WikiPage>( "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ created_at, updated_at, deleted_at \ FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \ ORDER BY updated_at DESC LIMIT $2 OFFSET $3", ) .bind(repo.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } } /// Get a single wiki page. pub async fn wiki_get_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; sqlx::query_as::<_, WikiPage>( "SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \ created_at, updated_at, deleted_at \ FROM wiki_page WHERE repo_id = $1 AND slug = $2 AND deleted_at IS NULL", ) .bind(repo.id) .bind(slug) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or_else(|| AppError::NotFound("Wiki page not found".into())) } /// Create a wiki page. pub async fn wiki_create_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, params: CreateWikiPageParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let title = required_text(params.title, "title")?; let content = required_text(params.content, "content")?; let slug = self.generate_wiki_slug(repo.id, &title).await?; let now = chrono::Utc::now(); let page_id = Uuid::now_v7(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query(set_local_user_id(user_uid)) .execute(&mut *txn) .await .map_err(AppError::Database)?; let page = sqlx::query_as::<_, WikiPage>( "INSERT INTO wiki_page (id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $6, 1, $7, $7) \ RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", ) .bind(page_id) .bind(repo.id) .bind(&slug) .bind(&title) .bind(&content) .bind(user_uid) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ VALUES ($1, $2, 1, $3, $4, $5, 'Initial creation', $6)", ) .bind(Uuid::now_v7()) .bind(page_id) .bind(&title) .bind(&content) .bind(user_uid) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(page) } /// Update a wiki page. pub async fn wiki_update_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, params: UpdateWikiPageParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; let new_title = params.title.unwrap_or(page.title.clone()); let new_content = params.content.unwrap_or(page.content.clone()); let new_version = page.version + 1; let commit_message = params .commit_message .unwrap_or_else(|| "Updated page".into()); let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query(set_local_user_id(user_uid)) .execute(&mut *txn) .await .map_err(AppError::Database)?; let updated = sqlx::query_as::<_, WikiPage>( "UPDATE wiki_page SET title = $1, content = $2, last_editor_id = $3, version = $4, updated_at = $5 \ WHERE id = $6 AND deleted_at IS NULL AND version = $7 \ RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", ) .bind(&new_title) .bind(&new_content) .bind(user_uid) .bind(new_version) .bind(now) .bind(page.id) .bind(page.version) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)? .ok_or(AppError::Conflict("page was modified concurrently; please refresh and try again".into()))?; sqlx::query( "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", ) .bind(Uuid::now_v7()) .bind(page.id) .bind(new_version) .bind(&new_title) .bind(&new_content) .bind(user_uid) .bind(&commit_message) .bind(now) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(updated) } /// Delete a wiki page using a soft delete. pub async fn wiki_delete_page( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) .await?; let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE wiki_page SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL", ) .bind(now) .bind(page.id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "wiki page not found") } /// Revert a wiki page to a historical version. pub async fn wiki_revert_to_version( &self, ctx: &Session, wk_name: &str, repo_name: &str, slug: &str, target_version: i32, commit_message: Option, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?; let revision = sqlx::query_as::<_, crate::models::wiki::WikiPageRevision>( "SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \ FROM wiki_page_revision WHERE page_id = $1 AND version = $2", ) .bind(page.id) .bind(target_version) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or_else(|| AppError::NotFound("Revision not found".into()))?; let msg = commit_message.unwrap_or_else(|| format!("Reverted to version {}", target_version)); self.wiki_update_page( ctx, wk_name, repo_name, slug, UpdateWikiPageParams { title: Some(revision.title), content: Some(revision.content), commit_message: Some(msg), }, ) .await } fn generate_slug(title: &str) -> String { let slug: String = title .to_lowercase() .chars() .map(|c| { if c.is_alphanumeric() || c.is_ascii_alphanumeric() { c } else { '-' } }) .collect::() .split('-') .filter(|s| !s.is_empty()) .collect::>() .join("-"); // If slug is empty (e.g., all non-ASCII characters), generate a fallback if slug.is_empty() { format!("page-{}", uuid::Uuid::now_v7().as_simple()) } else { slug } } async fn generate_wiki_slug(&self, repo_id: Uuid, title: &str) -> Result { let base_slug = Self::generate_slug(title); let mut slug = base_slug.clone(); let mut counter = 1; loop { let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM wiki_page WHERE repo_id = $1 AND slug = $2)", ) .bind(repo_id) .bind(&slug) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if !exists { return Ok(slug); } slug = format!("{}-{}", base_slug, counter); counter += 1; if counter > 100 { return Err(AppError::InternalServerError( "Failed to generate unique slug".into(), )); } } } }