Skip to main content

saorsa_core/discuss/
mod.rs

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