saorsa_core/discuss/
mod.rs

1//! Discuss system (Discourse-like) for long-form discussions and knowledge sharing
2//! 
3//! Features:
4//! - Categories and subcategories for organization
5//! - Topics with rich formatting and version history
6//! - Threaded replies with voting
7//! - Wiki mode for collaborative editing
8//! - Moderation with threshold-based decisions
9//! - Badges and reputation system
10
11use crate::identity::enhanced::{EnhancedIdentity, OrganizationId};
12use crate::storage::{StorageManager, keys, ttl};
13// Removed unused ThresholdGroup import
14use crate::quantum_crypto::types::GroupId;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::time::SystemTime;
18use thiserror::Error;
19use uuid::Uuid;
20
21/// Discuss errors
22#[derive(Debug, Error)]
23pub enum DiscussError {
24    #[error("Storage error: {0}")]
25    StorageError(#[from] crate::storage::StorageError),
26    
27    #[error("Category not found: {0}")]
28    CategoryNotFound(String),
29    
30    #[error("Topic not found: {0}")]
31    TopicNotFound(String),
32    
33    #[error("Permission denied: {0}")]
34    PermissionDenied(String),
35    
36    #[error("Invalid operation: {0}")]
37    InvalidOperation(String),
38}
39
40type Result<T> = std::result::Result<T, DiscussError>;
41
42/// Category identifier
43#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
44pub struct CategoryId(pub String);
45
46impl CategoryId {
47    pub fn new() -> Self {
48        Self(Uuid::new_v4().to_string())
49    }
50}
51
52/// Topic identifier
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub struct TopicId(pub String);
55
56impl TopicId {
57    pub fn new() -> Self {
58        Self(Uuid::new_v4().to_string())
59    }
60}
61
62/// Reply identifier
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub struct ReplyId(pub String);
65
66impl ReplyId {
67    pub fn new() -> Self {
68        Self(Uuid::new_v4().to_string())
69    }
70}
71
72/// User ID type
73pub type UserId = String;
74
75/// Category for organizing discussions
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Category {
78    pub id: CategoryId,
79    pub name: String,
80    pub description: String,
81    pub slug: String,
82    pub parent_id: Option<CategoryId>,
83    pub organization_id: Option<OrganizationId>,
84    pub access_level: AccessLevel,
85    pub moderator_group: Option<GroupId>,
86    pub settings: CategorySettings,
87    pub stats: CategoryStats,
88    pub created_at: SystemTime,
89    pub created_by: UserId,
90}
91
92/// Access level for categories
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub enum AccessLevel {
95    /// Anyone can view and post
96    Public,
97    
98    /// Anyone can view, members can post
99    Protected,
100    
101    /// Only members can view and post
102    Private(GroupId),
103    
104    /// Read-only for everyone except moderators
105    Announcement,
106}
107
108/// Category settings
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CategorySettings {
111    pub allow_polls: bool,
112    pub allow_wiki_posts: bool,
113    pub require_approval: bool,
114    pub min_trust_level: TrustLevel,
115    pub auto_close_days: Option<u32>,
116    pub slow_mode_minutes: Option<u32>,
117}
118
119impl Default for CategorySettings {
120    fn default() -> Self {
121        Self {
122            allow_polls: true,
123            allow_wiki_posts: true,
124            require_approval: false,
125            min_trust_level: TrustLevel::Basic,
126            auto_close_days: None,
127            slow_mode_minutes: None,
128        }
129    }
130}
131
132/// Category statistics
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
134pub struct CategoryStats {
135    pub topic_count: u64,
136    pub post_count: u64,
137    pub last_post_at: Option<SystemTime>,
138}
139
140/// User trust level
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
142pub enum TrustLevel {
143    New = 0,
144    Basic = 1,
145    Member = 2,
146    Regular = 3,
147    Leader = 4,
148}
149
150impl Default for TrustLevel {
151    fn default() -> Self {
152        TrustLevel::New
153    }
154}
155
156/// Discussion topic
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Topic {
159    pub id: TopicId,
160    pub category_id: CategoryId,
161    pub title: String,
162    pub slug: String,
163    pub content: TopicContent,
164    pub author: UserId,
165    pub tags: Vec<Tag>,
166    pub status: TopicStatus,
167    pub topic_type: TopicType,
168    pub stats: TopicStats,
169    pub created_at: SystemTime,
170    pub updated_at: SystemTime,
171    pub closed_at: Option<SystemTime>,
172    pub deleted_at: Option<SystemTime>,
173}
174
175/// Topic content with version history
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct TopicContent {
178    pub current_version: String,
179    pub format: ContentFormat,
180    pub versions: Vec<ContentVersion>,
181    pub wiki_editors: Vec<UserId>,
182}
183
184/// Content format
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub enum ContentFormat {
187    Markdown,
188    Html,
189    PlainText,
190}
191
192/// Content version for history
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct ContentVersion {
195    pub content: String,
196    pub author: UserId,
197    pub created_at: SystemTime,
198    pub edit_reason: Option<String>,
199}
200
201/// Topic status
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub enum TopicStatus {
204    Open,
205    Closed,
206    Archived,
207    Pinned,
208    PinnedGlobally,
209    Unlisted,
210}
211
212/// Topic type
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub enum TopicType {
215    Regular,
216    Wiki,
217    Poll,
218    Question,
219    Announcement,
220}
221
222/// Topic statistics
223#[derive(Debug, Clone, Serialize, Deserialize, Default)]
224pub struct TopicStats {
225    pub view_count: u64,
226    pub reply_count: u64,
227    pub like_count: i64,
228    pub bookmark_count: u64,
229    pub unique_viewers: u64,
230    pub last_reply_at: Option<SystemTime>,
231}
232
233/// Tag for categorization
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Tag {
236    pub name: String,
237    pub color: String,
238}
239
240/// Reply to a topic
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct Reply {
243    pub id: ReplyId,
244    pub topic_id: TopicId,
245    pub author: UserId,
246    pub content: String,
247    pub reply_to: Option<ReplyId>,
248    pub votes: VoteCount,
249    pub accepted_answer: bool,
250    pub created_at: SystemTime,
251    pub edited_at: Option<SystemTime>,
252    pub deleted_at: Option<SystemTime>,
253    pub reactions: Vec<Reaction>,
254}
255
256/// Vote count
257#[derive(Debug, Clone, Serialize, Deserialize, Default)]
258pub struct VoteCount {
259    pub upvotes: u64,
260    pub downvotes: u64,
261    pub score: i64,
262    pub voters: HashMap<UserId, VoteType>,
263}
264
265/// Vote type
266#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
267pub enum VoteType {
268    Up,
269    Down,
270}
271
272/// Reaction
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct Reaction {
275    pub emoji: String,
276    pub users: Vec<UserId>,
277}
278
279/// Poll in a topic
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct Poll {
282    pub id: String,
283    pub topic_id: TopicId,
284    pub question: String,
285    pub options: Vec<PollOption>,
286    pub poll_type: PollType,
287    pub closes_at: Option<SystemTime>,
288    pub results_visible: PollResultsVisibility,
289    pub voters: HashMap<UserId, Vec<usize>>,
290}
291
292/// Poll option
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct PollOption {
295    pub text: String,
296    pub votes: u64,
297}
298
299/// Poll type
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub enum PollType {
302    Single,
303    Multiple { max_choices: usize },
304    Ranked,
305}
306
307/// Poll results visibility
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub enum PollResultsVisibility {
310    Always,
311    OnVote,
312    OnClose,
313    Staff,
314}
315
316/// User badge
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Badge {
319    pub id: String,
320    pub name: String,
321    pub description: String,
322    pub icon: String,
323    pub badge_type: BadgeType,
324    pub granted_at: SystemTime,
325}
326
327/// Badge type
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub enum BadgeType {
330    Bronze,
331    Silver,
332    Gold,
333    Special(String),
334}
335
336/// User statistics in discussions
337#[derive(Debug, Clone, Serialize, Deserialize, Default)]
338pub struct UserStats {
339    pub topics_created: u64,
340    pub replies_posted: u64,
341    pub likes_given: u64,
342    pub likes_received: u64,
343    pub solutions_accepted: u64,
344    pub days_visited: u64,
345    pub posts_read: u64,
346    pub trust_level: TrustLevel,
347    pub badges: Vec<Badge>,
348}
349
350/// Moderation action
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct ModerationAction {
353    pub id: String,
354    pub action_type: ModerationType,
355    pub target: ModerationTarget,
356    pub moderator: UserId,
357    pub reason: String,
358    pub created_at: SystemTime,
359    pub expires_at: Option<SystemTime>,
360}
361
362/// Moderation type
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub enum ModerationType {
365    Close,
366    Pin,
367    Unlist,
368    Delete,
369    Move { to_category: CategoryId },
370    Merge { with_topic: TopicId },
371    Split { new_topic_id: TopicId },
372}
373
374/// Moderation target
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub enum ModerationTarget {
377    Topic(TopicId),
378    Reply(ReplyId),
379    User(UserId),
380}
381
382/// Discuss manager
383pub struct DiscussManager {
384    storage: StorageManager,
385    identity: EnhancedIdentity,
386}
387
388impl DiscussManager {
389    /// Create new discuss manager
390    pub fn new(storage: StorageManager, identity: EnhancedIdentity) -> Self {
391        Self { storage, identity }
392    }
393    
394    /// Create a new category
395    pub async fn create_category(
396        &mut self,
397        name: String,
398        description: String,
399        parent_id: Option<CategoryId>,
400        access_level: AccessLevel,
401        organization_id: Option<OrganizationId>,
402    ) -> Result<Category> {
403        let category = Category {
404            id: CategoryId::new(),
405            name: name.clone(),
406            description,
407            slug: self.slugify(&name),
408            parent_id,
409            organization_id,
410            access_level,
411            moderator_group: None,
412            settings: CategorySettings::default(),
413            stats: CategoryStats::default(),
414            created_at: SystemTime::now(),
415            created_by: self.identity.base_identity.user_id.clone(),
416        };
417        
418        // Store category
419        let key = format!("discuss:category:{}", category.id.0);
420        self.storage.store_encrypted(
421            &key,
422            &category,
423            ttl::PROFILE,
424            None,
425        ).await?;
426        
427        Ok(category)
428    }
429    
430    /// Create a new topic
431    pub async fn create_topic(
432        &mut self,
433        category_id: CategoryId,
434        title: String,
435        content: String,
436        topic_type: TopicType,
437        tags: Vec<Tag>,
438    ) -> Result<Topic> {
439        // Verify category exists and user has access
440        let category = self.get_category(&category_id).await?;
441        self.check_category_access(&category, false).await?;
442        
443        let topic = Topic {
444            id: TopicId::new(),
445            category_id: category_id.clone(),
446            title: title.clone(),
447            slug: self.slugify(&title),
448            content: TopicContent {
449                current_version: content.clone(),
450                format: ContentFormat::Markdown,
451                versions: vec![ContentVersion {
452                    content,
453                    author: self.identity.base_identity.user_id.clone(),
454                    created_at: SystemTime::now(),
455                    edit_reason: None,
456                }],
457                wiki_editors: vec![],
458            },
459            author: self.identity.base_identity.user_id.clone(),
460            tags,
461            status: TopicStatus::Open,
462            topic_type,
463            stats: TopicStats::default(),
464            created_at: SystemTime::now(),
465            updated_at: SystemTime::now(),
466            closed_at: None,
467            deleted_at: None,
468        };
469        
470        // Store topic
471        let key = keys::discuss_topic(&topic.id.0);
472        self.storage.store_encrypted(
473            &key,
474            &topic,
475            ttl::PROFILE,
476            None,
477        ).await?;
478        
479        // Update category stats
480        self.update_category_stats(&category_id, 1, 0).await?;
481        
482        Ok(topic)
483    }
484    
485    /// Post a reply
486    pub async fn post_reply(
487        &mut self,
488        topic_id: TopicId,
489        content: String,
490        reply_to: Option<ReplyId>,
491    ) -> Result<Reply> {
492        // Verify topic exists and is open
493        let topic = self.get_topic(&topic_id).await?;
494        if !matches!(topic.status, TopicStatus::Open) {
495            return Err(DiscussError::InvalidOperation("Topic is not open".to_string()));
496        }
497        
498        let reply = Reply {
499            id: ReplyId::new(),
500            topic_id: topic_id.clone(),
501            author: self.identity.base_identity.user_id.clone(),
502            content,
503            reply_to,
504            votes: VoteCount::default(),
505            accepted_answer: false,
506            created_at: SystemTime::now(),
507            edited_at: None,
508            deleted_at: None,
509            reactions: vec![],
510        };
511        
512        // Store reply
513        let key = keys::discuss_reply(&topic_id.0, &reply.id.0);
514        self.storage.store_encrypted(
515            &key,
516            &reply,
517            ttl::MESSAGE,
518            None,
519        ).await?;
520        
521        // Update topic stats
522        self.update_topic_stats(&topic_id, 0, 1).await?;
523        
524        Ok(reply)
525    }
526    
527    /// Vote on content
528    pub async fn vote(
529        &mut self,
530        target: VoteTarget,
531        vote_type: VoteType,
532    ) -> Result<()> {
533        match target {
534            VoteTarget::Topic(topic_id) => {
535                let mut topic = self.get_topic(&topic_id).await?;
536                // Update vote count (simplified)
537                match vote_type {
538                    VoteType::Up => topic.stats.like_count += 1,
539                    VoteType::Down => topic.stats.like_count -= 1,
540                }
541                
542                let key = keys::discuss_topic(&topic_id.0);
543                self.storage.store_encrypted(
544                    &key,
545                    &topic,
546                    ttl::PROFILE,
547                    None,
548                ).await?;
549            }
550            VoteTarget::Reply(topic_id, reply_id) => {
551                let key = keys::discuss_reply(&topic_id.0, &reply_id.0);
552                let mut reply: Reply = self.storage.get_encrypted(&key).await?;
553                
554                let user_id = &self.identity.base_identity.user_id;
555                reply.votes.voters.insert(user_id.clone(), vote_type);
556                
557                // Recalculate score
558                reply.votes.upvotes = reply.votes.voters.values()
559                    .filter(|&&v| matches!(v, VoteType::Up)).count() as u64;
560                reply.votes.downvotes = reply.votes.voters.values()
561                    .filter(|&&v| matches!(v, VoteType::Down)).count() as u64;
562                reply.votes.score = reply.votes.upvotes as i64 - reply.votes.downvotes as i64;
563                
564                self.storage.store_encrypted(
565                    &key,
566                    &reply,
567                    ttl::MESSAGE,
568                    None,
569                ).await?;
570            }
571        }
572        
573        Ok(())
574    }
575    
576    /// Edit topic (wiki mode)
577    pub async fn edit_topic(
578        &mut self,
579        topic_id: &TopicId,
580        new_content: String,
581        edit_reason: Option<String>,
582    ) -> Result<()> {
583        let mut topic = self.get_topic(topic_id).await?;
584        
585        // Check if user can edit
586        let user_id = &self.identity.base_identity.user_id;
587        let can_edit = topic.author == *user_id || 
588                      topic.content.wiki_editors.contains(user_id) ||
589                      matches!(topic.topic_type, TopicType::Wiki);
590        
591        if !can_edit {
592            return Err(DiscussError::PermissionDenied("Cannot edit topic".to_string()));
593        }
594        
595        // Add new version
596        topic.content.versions.push(ContentVersion {
597            content: new_content.clone(),
598            author: user_id.clone(),
599            created_at: SystemTime::now(),
600            edit_reason,
601        });
602        topic.content.current_version = new_content;
603        topic.updated_at = SystemTime::now();
604        
605        // Store updated topic
606        let key = keys::discuss_topic(&topic_id.0);
607        self.storage.store_encrypted(
608            &key,
609            &topic,
610            ttl::PROFILE,
611            None,
612        ).await?;
613        
614        Ok(())
615    }
616    
617    /// Get category by ID
618    async fn get_category(&self, category_id: &CategoryId) -> Result<Category> {
619        let key = format!("discuss:category:{}", category_id.0);
620        self.storage.get_encrypted(&key).await
621            .map_err(|_| DiscussError::CategoryNotFound(category_id.0.clone()))
622    }
623    
624    /// Get topic by ID
625    async fn get_topic(&self, topic_id: &TopicId) -> Result<Topic> {
626        let key = keys::discuss_topic(&topic_id.0);
627        self.storage.get_encrypted(&key).await
628            .map_err(|_| DiscussError::TopicNotFound(topic_id.0.clone()))
629    }
630    
631    /// Check category access
632    async fn check_category_access(&self, category: &Category, write: bool) -> Result<()> {
633        match &category.access_level {
634            AccessLevel::Public => Ok(()),
635            AccessLevel::Protected => {
636                if write {
637                    // Check if user is member
638                    // TODO: Implement membership check
639                    Ok(())
640                } else {
641                    Ok(())
642                }
643            }
644            AccessLevel::Private(group_id) => {
645                // Check if user is in threshold group
646                // TODO: Implement group membership check
647                Ok(())
648            }
649            AccessLevel::Announcement => {
650                if write {
651                    // Check if user is moderator
652                    // TODO: Implement moderator check
653                    Err(DiscussError::PermissionDenied("Announcement category".to_string()))
654                } else {
655                    Ok(())
656                }
657            }
658        }
659    }
660    
661    /// Update category statistics
662    async fn update_category_stats(
663        &mut self,
664        category_id: &CategoryId,
665        topic_delta: i64,
666        post_delta: i64,
667    ) -> Result<()> {
668        let mut category = self.get_category(category_id).await?;
669        
670        category.stats.topic_count = (category.stats.topic_count as i64 + topic_delta) as u64;
671        category.stats.post_count = (category.stats.post_count as i64 + post_delta) as u64;
672        category.stats.last_post_at = Some(SystemTime::now());
673        
674        let key = format!("discuss:category:{}", category_id.0);
675        self.storage.store_encrypted(
676            &key,
677            &category,
678            ttl::PROFILE,
679            None,
680        ).await?;
681        
682        Ok(())
683    }
684    
685    /// Update topic statistics
686    async fn update_topic_stats(
687        &mut self,
688        topic_id: &TopicId,
689        view_delta: i64,
690        reply_delta: i64,
691    ) -> Result<()> {
692        let mut topic = self.get_topic(topic_id).await?;
693        
694        topic.stats.view_count = (topic.stats.view_count as i64 + view_delta) as u64;
695        topic.stats.reply_count = (topic.stats.reply_count as i64 + reply_delta) as u64;
696        if reply_delta > 0 {
697            topic.stats.last_reply_at = Some(SystemTime::now());
698        }
699        
700        let key = keys::discuss_topic(&topic_id.0);
701        self.storage.store_encrypted(
702            &key,
703            &topic,
704            ttl::PROFILE,
705            None,
706        ).await?;
707        
708        Ok(())
709    }
710    
711    /// Convert string to URL-friendly slug
712    fn slugify(&self, text: &str) -> String {
713        text.to_lowercase()
714            .chars()
715            .map(|c| if c.is_alphanumeric() { c } else { '-' })
716            .collect::<String>()
717            .split('-')
718            .filter(|s| !s.is_empty())
719            .collect::<Vec<_>>()
720            .join("-")
721    }
722}
723
724/// Vote target
725pub enum VoteTarget {
726    Topic(TopicId),
727    Reply(TopicId, ReplyId),
728}