use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; use crate::models::repos::RepoRelease; use crate::service::RepoService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateReleaseParams { pub tag_name: String, pub title: String, pub body: Option, pub draft: Option, pub prerelease: Option, pub tag_id: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateReleaseParams { pub title: Option, pub body: Option, pub draft: Option, pub prerelease: Option, } impl RepoService { pub async fn repo_releases( &self, ctx: &Session, wk_name: &str, repo_name: &str, 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?; let repo_id = repo.id; self.ensure_repo_readable(user_uid, &repo).await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, RepoRelease>( "SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(repo_id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn repo_create_release( &self, ctx: &Session, wk_name: &str, repo_name: &str, params: CreateReleaseParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; let repo_id = repo.id; self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let tag_name = required_text(params.tag_name, "tag_name")?; let title = required_text(params.title, "title")?; let now = chrono::Utc::now(); let published_at = if params.draft.unwrap_or(false) { None } else { Some(now) }; let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let release = sqlx::query_as::<_, RepoRelease>( "INSERT INTO repo_release (id, repo_id, tag_id, tag_name, title, body, draft, prerelease, \ author_id, published_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at", ) .bind(Uuid::now_v7()) .bind(repo_id) .bind(params.tag_id) .bind(&tag_name) .bind(&title) .bind(¶ms.body) .bind(params.draft.unwrap_or(false)) .bind(params.prerelease.unwrap_or(false)) .bind(user_uid) .bind(published_at) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "UPDATE repo_stats SET releases_count = releases_count + 1, updated_at = $1 WHERE repo_id = $2", ) .bind(now) .bind(repo_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(release) } pub async fn repo_update_release( &self, ctx: &Session, wk_name: &str, repo_name: &str, release_id: Uuid, params: UpdateReleaseParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; let repo_id = repo.id; let actor_role = self .ensure_repo_role_at_least(user_uid, &repo, Role::Member) .await?; let current = sqlx::query_as::<_, RepoRelease>( "SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE id = $1 AND repo_id = $2 AND deleted_at IS NULL", ) .bind(release_id) .bind(repo_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("release not found".into()))?; if crate::service::repo::util::role_level(actor_role) < crate::service::repo::util::role_level(Role::Admin) && current.author_id != user_uid { return Err(AppError::Unauthorized); } let title = merge_optional_text(params.title, Some(current.title.clone())).unwrap_or(current.title); let body = merge_optional_text(params.body, current.body); let draft = params.draft.unwrap_or(current.draft); let prerelease = params.prerelease.unwrap_or(current.prerelease); let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query_as::<_, RepoRelease>( "UPDATE repo_release SET title = $1, body = $2, draft = $3, prerelease = $4, \ published_at = CASE WHEN $3 = false AND published_at IS NULL THEN $5 ELSE published_at END, \ updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at", ) .bind(&title) .bind(&body) .bind(draft) .bind(prerelease) .bind(now) .bind(release_id) .bind(repo_id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(result) } pub async fn repo_delete_release( &self, ctx: &Session, wk_name: &str, repo_name: &str, release_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; let repo_id = repo.id; self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) .await?; let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query( "UPDATE repo_release SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND deleted_at IS NULL", ) .bind(now) .bind(release_id) .bind(repo_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "release not found")?; sqlx::query( "UPDATE repo_stats SET releases_count = GREATEST(releases_count - 1, 0), updated_at = $1 WHERE repo_id = $2", ) .bind(now) .bind(repo_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } }