1use crate::identity::enhanced::{EnhancedIdentity, OrganizationId};
12use crate::storage::{StorageManager, keys, ttl};
13use 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#[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#[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#[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#[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
72pub type UserId = String;
74
75#[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#[derive(Debug, Clone, Serialize, Deserialize)]
94pub enum AccessLevel {
95 Public,
97
98 Protected,
100
101 Private(GroupId),
103
104 Announcement,
106}
107
108#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
186pub enum ContentFormat {
187 Markdown,
188 Html,
189 PlainText,
190}
191
192#[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#[derive(Debug, Clone, Serialize, Deserialize)]
203pub enum TopicStatus {
204 Open,
205 Closed,
206 Archived,
207 Pinned,
208 PinnedGlobally,
209 Unlisted,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub enum TopicType {
215 Regular,
216 Wiki,
217 Poll,
218 Question,
219 Announcement,
220}
221
222#[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#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Tag {
236 pub name: String,
237 pub color: String,
238}
239
240#[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#[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#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
267pub enum VoteType {
268 Up,
269 Down,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct Reaction {
275 pub emoji: String,
276 pub users: Vec<UserId>,
277}
278
279#[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#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct PollOption {
295 pub text: String,
296 pub votes: u64,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub enum PollType {
302 Single,
303 Multiple { max_choices: usize },
304 Ranked,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize)]
309pub enum PollResultsVisibility {
310 Always,
311 OnVote,
312 OnClose,
313 Staff,
314}
315
316#[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#[derive(Debug, Clone, Serialize, Deserialize)]
329pub enum BadgeType {
330 Bronze,
331 Silver,
332 Gold,
333 Special(String),
334}
335
336#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
376pub enum ModerationTarget {
377 Topic(TopicId),
378 Reply(ReplyId),
379 User(UserId),
380}
381
382pub struct DiscussManager {
384 storage: StorageManager,
385 identity: EnhancedIdentity,
386}
387
388impl DiscussManager {
389 pub fn new(storage: StorageManager, identity: EnhancedIdentity) -> Self {
391 Self { storage, identity }
392 }
393
394 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 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 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 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 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 self.update_category_stats(&category_id, 1, 0).await?;
481
482 Ok(topic)
483 }
484
485 pub async fn post_reply(
487 &mut self,
488 topic_id: TopicId,
489 content: String,
490 reply_to: Option<ReplyId>,
491 ) -> Result<Reply> {
492 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 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 self.update_topic_stats(&topic_id, 0, 1).await?;
523
524 Ok(reply)
525 }
526
527 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 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 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 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 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 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 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 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 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 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 Ok(())
640 } else {
641 Ok(())
642 }
643 }
644 AccessLevel::Private(group_id) => {
645 Ok(())
648 }
649 AccessLevel::Announcement => {
650 if write {
651 Err(DiscussError::PermissionDenied("Announcement category".to_string()))
654 } else {
655 Ok(())
656 }
657 }
658 }
659 }
660
661 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 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 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
724pub enum VoteTarget {
726 Topic(TopicId),
727 Reply(TopicId, ReplyId),
728}