1use crate::identity::enhanced::{EnhancedIdentity, OrganizationId};
25use crate::storage::{StorageManager, keys, ttl};
26use 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#[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#[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#[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#[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
103pub type UserId = String;
105
106#[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#[derive(Debug, Clone, Serialize, Deserialize)]
125pub enum AccessLevel {
126 Public,
128
129 Protected,
131
132 Private(GroupId),
134
135 Announcement,
137}
138
139#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
212pub enum ContentFormat {
213 Markdown,
214 Html,
215 PlainText,
216}
217
218#[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#[derive(Debug, Clone, Serialize, Deserialize)]
229pub enum TopicStatus {
230 Open,
231 Closed,
232 Archived,
233 Pinned,
234 PinnedGlobally,
235 Unlisted,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub enum TopicType {
241 Regular,
242 Wiki,
243 Poll,
244 Question,
245 Announcement,
246}
247
248#[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#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct Tag {
262 pub name: String,
263 pub color: String,
264}
265
266#[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#[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#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
293pub enum VoteType {
294 Up,
295 Down,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct Reaction {
301 pub emoji: String,
302 pub users: Vec<UserId>,
303}
304
305#[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#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct PollOption {
321 pub text: String,
322 pub votes: u64,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub enum PollType {
328 Single,
329 Multiple { max_choices: usize },
330 Ranked,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335pub enum PollResultsVisibility {
336 Always,
337 OnVote,
338 OnClose,
339 Staff,
340}
341
342#[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#[derive(Debug, Clone, Serialize, Deserialize)]
355pub enum BadgeType {
356 Bronze,
357 Silver,
358 Gold,
359 Special(String),
360}
361
362#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
402pub enum ModerationTarget {
403 Topic(TopicId),
404 Reply(ReplyId),
405 User(UserId),
406}
407
408pub struct DiscussManager {
410 storage: StorageManager,
411 identity: EnhancedIdentity,
412}
413
414impl DiscussManager {
415 pub fn new(storage: StorageManager, identity: EnhancedIdentity) -> Self {
417 Self { storage, identity }
418 }
419
420 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 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 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 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 let key = keys::discuss_topic(&topic.id.0);
495 self.storage
496 .store_encrypted(&key, &topic, ttl::PROFILE, None)
497 .await?;
498
499 self.update_category_stats(&category_id, 1, 0).await?;
501
502 Ok(topic)
503 }
504
505 pub async fn post_reply(
507 &mut self,
508 topic_id: TopicId,
509 content: String,
510 reply_to: Option<ReplyId>,
511 ) -> Result<Reply> {
512 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 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 self.update_topic_stats(&topic_id, 0, 1).await?;
542
543 Ok(reply)
544 }
545
546 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 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 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 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 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 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 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 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 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 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 Ok(())
660 } else {
661 Ok(())
662 }
663 }
664 AccessLevel::Private(_group_id) => {
665 Ok(())
668 }
669 AccessLevel::Announcement => {
670 if write {
671 Err(DiscussError::PermissionDenied(
674 "Announcement category".to_string(),
675 ))
676 } else {
677 Ok(())
678 }
679 }
680 }
681 }
682
683 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 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 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
740pub enum VoteTarget {
742 Topic(TopicId),
743 Reply(TopicId, ReplyId),
744}