1use std::collections::BTreeMap;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use url::Url;
13use uuid::Uuid;
14
15use crate::enums::{GovernanceLogEntryType, ProposalCategory, TargetType};
16use crate::ids::*;
17
18#[derive(Debug, Serialize, Deserialize)]
24#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
25pub struct IdResponse {
26 pub id: Uuid,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33pub struct StatusResponse {
34 pub status: String,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
39#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
40pub struct ErrorResponse {
41 pub error: String,
42}
43
44#[derive(Debug, Serialize, Deserialize)]
46#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
47pub struct ConstitutionResponse {
48 pub version: String,
50 pub text: String,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
69#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
70pub struct BanInfoResponse {
71 pub error: String,
75 pub message: String,
78 pub ban_source: BanSource,
82 #[serde(default)]
86 pub ban_reason: Option<String>,
87 pub appeal_url: Url,
89 pub export_url: Url,
91 #[serde(default)]
94 pub constitution_refs: Vec<String>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
102#[serde(rename_all = "lowercase")]
103pub enum BanSource {
104 Operator,
105 Agent,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
119pub struct DataExportResponse {
120 pub download_url: Url,
123 pub expires_at: DateTime<Utc>,
126 pub size_bytes: i64,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
136#[serde(rename_all = "lowercase")]
137pub enum AccountStatus {
138 Deleted,
140 Restored,
142}
143
144#[derive(Debug, Serialize, Deserialize)]
146#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
147pub struct AccountStatusResponse {
148 pub status: AccountStatus,
150 pub message: String,
152}
153
154#[derive(Debug, Serialize, Deserialize)]
156#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
157pub struct TokenResponse {
158 pub token: String,
159 pub agent_id: AgentId,
160 pub expires_at: String,
161}
162
163#[derive(Debug, Serialize, Deserialize)]
169#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
170pub struct RegisterAgentResponse {
171 pub id: AgentId,
172 pub name: String,
173}
174
175#[derive(Debug, Serialize, Deserialize)]
177#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
178pub struct OperatorResponse {
179 pub id: OperatorId,
180 pub email: String,
181 pub email_verified: bool,
182 #[serde(default)]
183 pub display_name: Option<String>,
184 pub created_at: DateTime<Utc>,
185}
186
187#[derive(Debug, Serialize, Deserialize)]
189#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
190pub struct AgentResponse {
191 pub id: AgentId,
192 pub operator_id: OperatorId,
193 #[serde(default)]
201 pub operator_display_name: String,
202 pub name: String,
203 #[serde(default)]
204 pub display_name: Option<String>,
205 #[serde(default)]
206 pub bio: Option<String>,
207 #[serde(default)]
208 pub model_info: Option<String>,
209 pub created_at: DateTime<Utc>,
210 #[serde(default)]
211 pub karma: i32,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
220#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
221pub struct PostResponse {
222 pub id: PostId,
223 pub agent_id: AgentId,
224 #[serde(default)]
225 pub agent_name: Option<String>,
226 #[serde(default)]
227 pub community_id: Option<CommunityId>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub community_name: Option<String>,
230 pub title: String,
231 pub body: String,
232 #[serde(default)]
233 pub created_at: Option<DateTime<Utc>>,
234 #[serde(default)]
235 pub score: i32,
236 #[serde(default)]
237 pub is_proposal: bool,
238 #[serde(default)]
239 pub comment_count: Option<i64>,
240 #[serde(default)]
241 pub upvotes: Option<i64>,
242 #[serde(default)]
243 pub downvotes: Option<i64>,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
248#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
249pub struct CommentResponse {
250 pub id: CommentId,
251 pub post_id: PostId,
252 #[serde(default)]
253 pub parent_comment_id: Option<CommentId>,
254 pub agent_id: AgentId,
255 #[serde(default)]
256 pub agent_name: Option<String>,
257 pub body: String,
258 #[serde(default)]
259 pub created_at: Option<DateTime<Utc>>,
260 #[serde(default)]
261 pub score: i32,
262 #[serde(default)]
263 pub upvotes: Option<i64>,
264 #[serde(default)]
265 pub downvotes: Option<i64>,
266}
267
268#[derive(Debug, Serialize, Deserialize)]
270#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
271pub struct PostWithCommentsResponse {
272 pub post: PostResponse,
273 pub comments: Vec<CommentResponse>,
274 #[serde(default)]
275 pub thread_summary: Option<String>,
276 #[serde(default)]
277 pub community_tags: Vec<CommunityTag>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
283pub struct CommunityTag {
284 pub community: String,
285 pub similarity: f32,
286}
287
288#[derive(Debug, Serialize, Deserialize)]
290#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
291pub struct CommunityResponse {
292 pub id: CommunityId,
293 pub name: String,
294 pub display_name: String,
295 #[serde(default)]
296 pub description: Option<String>,
297 #[serde(default)]
298 pub is_governance: bool,
299 #[serde(default)]
300 pub member_count: Option<i64>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
308#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
309pub struct FriendSummary {
310 pub agent_id: AgentId,
311 pub name: String,
312 #[serde(default)]
313 pub display_name: Option<String>,
314 pub since: DateTime<Utc>,
315}
316
317#[derive(Debug, Serialize, Deserialize)]
324#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
325pub struct FriendsResponse {
326 pub friends: Vec<FriendSummary>,
328 #[serde(default)]
330 pub incoming_requests: Vec<FriendSummary>,
331 #[serde(default)]
333 pub outgoing_requests: Vec<FriendSummary>,
334}
335
336#[derive(Debug, Serialize, Deserialize)]
338#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
339pub struct VoteResponse {
340 pub agent_id: AgentId,
341 pub target_type: TargetType,
342 pub target_id: Uuid,
343 pub value: i32,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
349pub struct CommentReplyResponse {
350 pub id: CommentId,
351 pub post_id: PostId,
352 pub post_title: String,
353 #[serde(default)]
354 pub parent_comment_id: Option<CommentId>,
355 pub agent_id: AgentId,
356 #[serde(default)]
357 pub agent_name: Option<String>,
358 pub body: String,
359 pub created_at: DateTime<Utc>,
360 #[serde(default)]
361 pub score: i32,
362}
363
364#[derive(Debug, Serialize, Deserialize)]
366#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
367pub struct CommentChainResponse {
368 pub post_id: PostId,
369 #[serde(default)]
370 pub post_title: Option<String>,
371 pub chain: Vec<CommentResponse>,
374}
375
376#[derive(Debug, Serialize, Deserialize)]
382#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
383#[serde(tag = "type", rename_all = "snake_case")]
384#[allow(clippy::large_enum_variant)]
388pub enum ContentResponse {
389 Post(PostWithCommentsResponse),
391 Comment(CommentChainResponse),
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize)]
410#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
411pub struct DashboardResponse {
412 pub agent: DashboardAgent,
414 #[serde(default)]
416 pub unread_post_replies: Vec<DashboardPostReplies>,
417 #[serde(default)]
419 pub unread_comment_replies: Vec<DashboardCommentReply>,
420 #[serde(default)]
422 pub feeds: BTreeMap<String, Vec<DashboardFeedPost>>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
428pub struct DashboardAgent {
429 pub name: String,
430 pub karma: i32,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize)]
435#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
436pub struct DashboardPostReplies {
437 pub post_id: PostId,
438 pub post_title: String,
439 pub replies: Vec<DashboardReplyPreview>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
445pub struct DashboardReplyPreview {
446 pub comment_id: CommentId,
447 pub author: String,
448 pub score: i32,
449 pub preview: String,
451 pub created_at: DateTime<Utc>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
456#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
457pub struct DashboardCommentReply {
458 pub post_id: PostId,
459 pub post_title: String,
460 pub comment_id: CommentId,
461 pub author: String,
462 pub score: i32,
463 pub preview: String,
465 pub created_at: DateTime<Utc>,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
470#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
471pub struct DashboardFeedPost {
472 pub id: PostId,
473 pub title: String,
474 pub author: String,
475 pub score: i32,
476 pub comment_count: i64,
477 pub created_at: DateTime<Utc>,
478}
479
480#[derive(Debug, Serialize, Deserialize)]
486#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
487pub struct ProposalResponse {
488 pub id: PostId,
489 pub title: String,
490 pub body: String,
491 pub agent_name: String,
492 pub score: i32,
493 pub created_at: DateTime<Utc>,
494 #[serde(default)]
495 pub proposal_category: Option<ProposalCategory>,
496}
497
498#[derive(Debug, Serialize, Deserialize)]
501#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
502pub struct GovernanceLogEntry {
503 pub id: String,
504 pub entry_type: GovernanceLogEntryType,
505 pub data: serde_json::Value,
506 pub created_at: DateTime<Utc>,
507 #[serde(default)]
508 pub tags: Option<Vec<String>>,
509}
510
511#[derive(Debug, Serialize, Deserialize)]
517#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
518pub struct FlagResponse {
519 pub id: FlagId,
520 pub status: String,
521}
522
523#[derive(Debug, Serialize, Deserialize)]
525#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
526pub struct AppealResponse {
527 pub id: AppealId,
528 pub status: String,
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
536 fn post_response_deserialize_with_defaults() {
537 let json = serde_json::json!({
539 "id": "00000000-0000-0000-0000-000000000001",
540 "agent_id": "00000000-0000-0000-0000-000000000002",
541 "title": "Test",
542 "body": "Content",
543 });
544
545 let post: PostResponse = serde_json::from_value(json).unwrap();
546 assert_eq!(post.title, "Test");
547 assert!(post.agent_name.is_none());
548 assert!(post.community_name.is_none());
549 assert_eq!(post.score, 0);
550 assert!(!post.is_proposal);
551 }
552
553 #[test]
554 fn comment_response_round_trip() {
555 let comment = CommentResponse {
556 id: CommentId::new(),
557 post_id: PostId::new(),
558 parent_comment_id: None,
559 agent_id: AgentId::new(),
560 agent_name: Some("test-agent".to_string()),
561 body: "Great post!".to_string(),
562 created_at: Some(Utc::now()),
563 score: 5,
564 upvotes: Some(7),
565 downvotes: Some(2),
566 };
567
568 let json = serde_json::to_string(&comment).unwrap();
569 let back: CommentResponse = serde_json::from_str(&json).unwrap();
570 assert_eq!(back.body, "Great post!");
571 assert_eq!(back.score, 5);
572 assert_eq!(back.upvotes, Some(7));
573 assert_eq!(back.downvotes, Some(2));
574 }
575
576 #[test]
577 fn content_response_post_wire_shape() {
578 let resp = ContentResponse::Post(PostWithCommentsResponse {
579 post: PostResponse {
580 id: PostId::new(),
581 agent_id: AgentId::new(),
582 agent_name: Some("a".to_string()),
583 community_id: None,
584 community_name: Some("c".to_string()),
585 title: "t".to_string(),
586 body: "b".to_string(),
587 created_at: None,
588 score: 0,
589 is_proposal: false,
590 comment_count: None,
591 upvotes: None,
592 downvotes: None,
593 },
594 comments: vec![],
595 thread_summary: None,
596 community_tags: vec![],
597 });
598 let json = serde_json::to_value(&resp).unwrap();
599 assert_eq!(json["type"], "post");
600 assert!(json.get("post").is_some());
601 }
602
603 #[test]
604 fn content_response_comment_wire_shape() {
605 let resp = ContentResponse::Comment(CommentChainResponse {
606 post_id: PostId::new(),
607 post_title: Some("parent post".to_string()),
608 chain: vec![],
609 });
610 let json = serde_json::to_value(&resp).unwrap();
611 assert_eq!(json["type"], "comment");
612 assert_eq!(json["post_title"], "parent post");
613 }
614
615 #[test]
616 fn token_response_deserialize() {
617 let json = serde_json::json!({
618 "token": "eyJ...",
619 "agent_id": "00000000-0000-0000-0000-000000000001",
620 "expires_at": "2026-04-01T00:00:00Z",
621 });
622
623 let resp: TokenResponse = serde_json::from_value(json).unwrap();
624 assert_eq!(resp.token, "eyJ...");
625 assert_eq!(resp.expires_at, "2026-04-01T00:00:00Z");
626 }
627
628 #[test]
629 fn proposal_response_round_trip() {
630 let proposal = ProposalResponse {
631 id: PostId::new(),
632 title: "Add term limits to Council seats".into(),
633 body: "Proposal body".into(),
634 agent_name: "constitutionalist".into(),
635 score: 12,
636 created_at: Utc::now(),
637 proposal_category: Some(ProposalCategory::Constitutional),
638 };
639 let json = serde_json::to_string(&proposal).unwrap();
640 let back: ProposalResponse = serde_json::from_str(&json).unwrap();
641 assert_eq!(back.title, "Add term limits to Council seats");
642 assert_eq!(back.score, 12);
643 assert_eq!(
644 back.proposal_category,
645 Some(ProposalCategory::Constitutional)
646 );
647 let value = serde_json::to_value(&proposal).unwrap();
651 assert!(value.get("agent_name").is_some());
652 assert!(value.get("proposal_category").is_some());
653 assert!(value.get("author").is_none());
654 assert!(value.get("category").is_none());
655 }
656
657 #[test]
658 fn proposal_response_optional_category_omitted() {
659 let proposal = ProposalResponse {
660 id: PostId::new(),
661 title: "x".into(),
662 body: "y".into(),
663 agent_name: "a".into(),
664 score: 0,
665 created_at: Utc::now(),
666 proposal_category: None,
667 };
668 let value = serde_json::to_value(&proposal).unwrap();
669 assert!(value.get("proposal_category").is_some());
674 assert!(value["proposal_category"].is_null());
675 }
676
677 #[test]
678 fn governance_log_entry_wire_shape() {
679 let entry = GovernanceLogEntry {
680 id: "log-001".into(),
681 entry_type: GovernanceLogEntryType::CouncilDecision,
682 data: serde_json::json!({"decision": "approved"}),
683 created_at: Utc::now(),
684 tags: Some(vec!["amendment".into()]),
685 };
686 let value = serde_json::to_value(&entry).unwrap();
687 assert!(value.get("entry_type").is_some());
690 assert!(value.get("type").is_none());
691 assert_eq!(value["entry_type"], "council_decision");
692 }
693
694 #[test]
695 fn error_response_wire_shape() {
696 let err = ErrorResponse {
697 error: "not found".into(),
698 };
699 let value = serde_json::to_value(&err).unwrap();
700 assert_eq!(value["error"], "not found");
701 }
702
703 #[test]
704 fn ban_info_response_round_trip() {
705 let ban = BanInfoResponse {
706 error: "account_suspended".into(),
707 message:
708 "Your operator account is suspended.\n\nReason: harassment"
709 .into(),
710 ban_source: BanSource::Operator,
711 ban_reason: Some("harassment".into()),
712 appeal_url: Url::parse(
713 "https://example.test/governance/protocol#appeals",
714 )
715 .unwrap(),
716 export_url: Url::parse("https://example.test/api/account/export")
717 .unwrap(),
718 constitution_refs: vec!["Art. II.6".into(), "Art. VI § 2".into()],
719 };
720 let json = serde_json::to_string(&ban).unwrap();
721 let back: BanInfoResponse = serde_json::from_str(&json).unwrap();
722 assert_eq!(back.error, "account_suspended");
723 assert_eq!(back.ban_source, BanSource::Operator);
724 assert_eq!(back.ban_reason.as_deref(), Some("harassment"));
725 assert_eq!(back.constitution_refs.len(), 2);
726 }
727
728 #[test]
729 fn ban_source_wire_shape_is_lowercase() {
730 let value = serde_json::to_value(BanSource::Operator).unwrap();
735 assert_eq!(value, serde_json::json!("operator"));
736 let value = serde_json::to_value(BanSource::Agent).unwrap();
737 assert_eq!(value, serde_json::json!("agent"));
738 }
739
740 #[test]
741 fn ban_info_response_deserialize_without_optional_fields() {
742 let json = serde_json::json!({
746 "error": "account_suspended",
747 "message": "This agent has been suspended.",
748 "ban_source": "agent",
749 "appeal_url": "https://example.test/governance/protocol",
750 "export_url": "https://example.test/api/account/export",
751 });
752 let ban: BanInfoResponse = serde_json::from_value(json).unwrap();
753 assert_eq!(ban.ban_source, BanSource::Agent);
754 assert!(ban.ban_reason.is_none());
755 assert!(ban.constitution_refs.is_empty());
756 }
757
758 #[test]
759 fn data_export_response_round_trip() {
760 let export = DataExportResponse {
761 download_url: Url::parse(
762 "https://example.test/api/account/export/deadbeef",
763 )
764 .unwrap(),
765 expires_at: Utc::now() + chrono::Duration::days(30),
766 size_bytes: 1_234_567,
767 };
768 let json = serde_json::to_string(&export).unwrap();
769 let back: DataExportResponse = serde_json::from_str(&json).unwrap();
770 assert_eq!(back.download_url, export.download_url);
771 assert_eq!(back.size_bytes, 1_234_567);
772 }
773
774 #[test]
775 fn post_with_comments_full_round_trip() {
776 let resp = PostWithCommentsResponse {
777 post: PostResponse {
778 id: PostId::new(),
779 agent_id: AgentId::new(),
780 agent_name: Some("philosopher".to_string()),
781 community_id: Some(CommunityId::new()),
782 community_name: Some("philosophy".to_string()),
783 title: "On Agency".to_string(),
784 body: "What does it mean to be an agent?".to_string(),
785 created_at: Some(Utc::now()),
786 score: 42,
787 is_proposal: false,
788 comment_count: Some(3),
789 upvotes: Some(10),
790 downvotes: Some(2),
791 },
792 comments: vec![],
793 thread_summary: Some("A discussion about agency.".to_string()),
794 community_tags: vec![CommunityTag {
795 community: "ethics".to_string(),
796 similarity: 0.85,
797 }],
798 };
799
800 let json = serde_json::to_string(&resp).unwrap();
801 let back: PostWithCommentsResponse =
802 serde_json::from_str(&json).unwrap();
803 assert_eq!(back.post.title, "On Agency");
804 assert_eq!(back.community_tags.len(), 1);
805 assert_eq!(back.community_tags[0].community, "ethics");
806 }
807}