use db::conn::sea_orm::*; use db::repo::repo_topic as topic_entity; use time::OffsetDateTime; use uuid::Uuid; use crate::error::{RepoError, RepoResult}; use crate::features::RepoService; use crate::path::{normalize_topic, sanitize_topics}; use super::types::{RepoTopicOptions, TopicList, TopicSearchQuery, TopicSearchResult, TopicView}; fn require_authenticated(actor: Uuid) -> RepoResult<()> { if actor.is_nil() { return Err(RepoError::Unauthorized("authentication required".into())); } Ok(()) } async fn ensure_topic(svc: &RepoService, topic_name: &str) -> RepoResult { let db = svc.db.writer(); if let Some(t) = topic_entity::Entity::find() .filter(topic_entity::Column::Name.eq(topic_name)) .one(&db) .await? { return Ok(t); } let now = OffsetDateTime::now_utc(); let active: topic_entity::ActiveModel = topic_entity::ActiveModel { id: Set(Uuid::new_v4()), name: Set(topic_name.to_string()), created_at: Set(now), updated_at: Set(now), }; Ok(active.insert(&db).await?) } pub async fn list_repo_topics( svc: &RepoService, actor: Uuid, repo_id: Uuid, ) -> RepoResult { let _ = actor; let db = svc.db.reader(); let links = db::repo::repo_relation_topic::Entity::find() .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id)) .all(&db) .await?; let mut names: Vec = Vec::with_capacity(links.len()); for link in links { if let Some(t) = topic_entity::Entity::find_by_id(link.topic_id) .one(&db) .await? { names.push(t.name); } } names.sort(); names.dedup(); Ok(TopicList { topics: names }) } pub async fn update_repo_topics( svc: &RepoService, actor: Uuid, repo_id: Uuid, input: RepoTopicOptions, ) -> RepoResult<()> { require_authenticated(actor)?; let normalized = sanitize_topics(&input.topics, svc.config.max_topics_per_repo)?; let db = svc.db.writer(); let existing = db::repo::repo_relation_topic::Entity::find() .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id)) .all(&db) .await?; for r in existing { if let Some(t) = topic_entity::Entity::find_by_id(r.topic_id) .one(&db) .await? { if !normalized.contains(&t.name) { db::repo::repo_relation_topic::Entity::delete_by_id(r.id) .exec(&db) .await?; } } } for name in &normalized { let topic = ensure_topic(svc, name).await?; let exists = db::repo::repo_relation_topic::Entity::find() .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id)) .filter(db::repo::repo_relation_topic::Column::TopicId.eq(topic.id)) .one(&db) .await?; if exists.is_none() { let active: db::repo::repo_relation_topic::ActiveModel = db::repo::repo_relation_topic::ActiveModel { id: Set(Uuid::new_v4()), repo_id: Set(repo_id), topic_id: Set(topic.id), created_at: Set(OffsetDateTime::now_utc()), }; active.insert(&db).await?; } } Ok(()) } pub async fn add_repo_topic( svc: &RepoService, actor: Uuid, repo_id: Uuid, topic: &str, ) -> RepoResult<()> { require_authenticated(actor)?; let name = normalize_topic(topic)?; let current = db::repo::repo_relation_topic::Entity::find() .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id)) .count(&svc.db.reader()) .await?; if current >= svc.config.max_topics_per_repo as u64 { return Err(RepoError::BadRequest(format!( "too many topics (max {})", svc.config.max_topics_per_repo ))); } let topic_model = ensure_topic(svc, &name).await?; let db = svc.db.writer(); let exists = db::repo::repo_relation_topic::Entity::find() .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id)) .filter(db::repo::repo_relation_topic::Column::TopicId.eq(topic_model.id)) .one(&db) .await?; if exists.is_some() { return Ok(()); } let active: db::repo::repo_relation_topic::ActiveModel = db::repo::repo_relation_topic::ActiveModel { id: Set(Uuid::new_v4()), repo_id: Set(repo_id), topic_id: Set(topic_model.id), created_at: Set(OffsetDateTime::now_utc()), }; active.insert(&db).await?; Ok(()) } pub async fn delete_repo_topic( svc: &RepoService, actor: Uuid, repo_id: Uuid, topic: &str, ) -> RepoResult<()> { require_authenticated(actor)?; let name = normalize_topic(topic)?; let db = svc.db.writer(); let topic_model = topic_entity::Entity::find() .filter(topic_entity::Column::Name.eq(&name)) .one(&db) .await? .ok_or_else(|| RepoError::NotFound(format!("topic {name}")))?; db::repo::repo_relation_topic::Entity::delete_many() .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id)) .filter(db::repo::repo_relation_topic::Column::TopicId.eq(topic_model.id)) .exec(&db) .await?; Ok(()) } pub async fn search_topics( svc: &RepoService, actor: Uuid, q: TopicSearchQuery, ) -> RepoResult { let _ = actor; let limit = q.limit.unwrap_or(svc.config.default_pagination); let offset = q.page.unwrap_or(0).saturating_mul(limit); let limit = std::cmp::min(limit, svc.config.max_pagination); let db = svc.db.reader(); let mut query = topic_entity::Entity::find(); if let Some(s) = q.q.as_deref() { if !s.is_empty() { query = query.filter(topic_entity::Column::Name.contains(s)); } } let items = query .order_by_asc(topic_entity::Column::Name) .limit(Some(limit)) .offset(Some(offset)) .all(&db) .await? .into_iter() .map(|t| TopicView { id: t.id, name: t.name.clone(), repo_count: 0, created_at: super::types::fmt_ts_pub(t.created_at), updated_at: super::types::fmt_ts_pub(t.updated_at), }) .collect(); Ok(TopicSearchResult { topics: items }) }