refactor(tests): reformat code and update dependency management
- Reorganized import statements in adapter tests for better readability - Replaced or_insert_with(Vec::new) with or_default() in test closures - Updated Cargo.lock with new dependency versions and checksums - Added TLS features to tonic dependency configuration - Included sqlx, chrono, and uuid dependencies with specific features - Added jsonwebtoken and arc-swap as project dependencies - Reformatted assertion statements to comply with line length limits - Adjusted base64 import order in engine codec module - Updated protobuf include statement formatting
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
//! Core message model — maps to PostgreSQL `message` table.
|
||||
//!
|
||||
//! Discord-style: `body` holds plain text / markdown, rich content lives in
|
||||
//! companion tables (attachment, embed, poll, reaction, mention).
|
||||
//! IDs are UUID v7 (time-ordered) so `ORDER BY id` = chronological order.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Discriminator for system / event messages vs. regular user messages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageType {
|
||||
/// Regular user message (text / markdown).
|
||||
#[default]
|
||||
Text,
|
||||
/// System-generated notice (e.g. "user joined the channel").
|
||||
System,
|
||||
/// Channel event (pinned a message, changed topic, etc.).
|
||||
Event,
|
||||
/// Forum article / long-form post displayed as waterfall cards.
|
||||
Article,
|
||||
}
|
||||
|
||||
impl MessageType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text => "text",
|
||||
Self::System => "system",
|
||||
Self::Event => "event",
|
||||
Self::Article => "article",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_lossy(s: &str) -> Self {
|
||||
match s {
|
||||
"system" => Self::System,
|
||||
"event" => Self::Event,
|
||||
"article" => Self::Article,
|
||||
_ => Self::Text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MessageType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct mapping of the `message` table row.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Message {
|
||||
/// UUID v7 — time-ordered primary key.
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
/// Thread this message belongs to (NULL = not threaded).
|
||||
pub thread_id: Option<Uuid>,
|
||||
/// Direct reply reference (NULL = top-level message).
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
/// "text" | "system" | "event"
|
||||
pub message_type: String,
|
||||
/// Plain text or markdown body.
|
||||
pub body: String,
|
||||
/// Extensible metadata (flags, locale, interaction ref, etc.).
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub pinned: bool,
|
||||
/// True for bot / system generated messages.
|
||||
pub system: bool,
|
||||
pub edited_at: Option<DateTime<Utc>>,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Lightweight author info embedded in [`MessageDetail`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthorInfo {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub is_bot: bool,
|
||||
}
|
||||
|
||||
/// Message with resolved author and reaction/attachment aggregates.
|
||||
/// Returned by read APIs; never stored directly.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageDetail {
|
||||
#[serde(flatten)]
|
||||
pub message: Message,
|
||||
pub author: AuthorInfo,
|
||||
/// Aggregated reaction counts: `{ "👍": 3, "🎉": 1 }`.
|
||||
pub reactions: std::collections::HashMap<String, i64>,
|
||||
pub attachment_count: i64,
|
||||
pub embed_count: i64,
|
||||
/// Whether the current user has bookmarked this message.
|
||||
pub bookmarked: bool,
|
||||
/// Reply chain depth (0 = top-level).
|
||||
pub reply_depth: i32,
|
||||
}
|
||||
|
||||
/// Generate a new UUID v7 (time-ordered) for message IDs.
|
||||
pub fn new_message_id() -> Uuid {
|
||||
Uuid::now_v7()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_message_type_roundtrip() {
|
||||
let t = MessageType::Text;
|
||||
assert_eq!(t.as_str(), "text");
|
||||
assert_eq!(MessageType::from_str_lossy("text"), MessageType::Text);
|
||||
assert_eq!(MessageType::from_str_lossy("system"), MessageType::System);
|
||||
assert_eq!(MessageType::from_str_lossy("unknown"), MessageType::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_id_ordering() {
|
||||
// UUID v7 IDs generated later should sort after earlier ones.
|
||||
let a = new_message_id();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
let b = new_message_id();
|
||||
assert!(b > a, "UUID v7 should be time-ordered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_detail_serialize() {
|
||||
let msg = Message {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
author_id: Uuid::now_v7(),
|
||||
thread_id: None,
|
||||
reply_to_message_id: None,
|
||||
message_type: "text".to_string(),
|
||||
body: "hello world".to_string(),
|
||||
metadata: None,
|
||||
pinned: false,
|
||||
system: false,
|
||||
edited_at: None,
|
||||
deleted_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let detail = MessageDetail {
|
||||
message: msg,
|
||||
author: AuthorInfo {
|
||||
id: Uuid::now_v7(),
|
||||
username: "alice".to_string(),
|
||||
display_name: Some("Alice".to_string()),
|
||||
avatar_url: None,
|
||||
is_bot: false,
|
||||
},
|
||||
reactions: std::collections::HashMap::from([
|
||||
("👍".to_string(), 3),
|
||||
("🎉".to_string(), 1),
|
||||
]),
|
||||
attachment_count: 0,
|
||||
embed_count: 0,
|
||||
bookmarked: false,
|
||||
reply_depth: 0,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&detail).unwrap();
|
||||
assert_eq!(json["body"], "hello world");
|
||||
assert_eq!(json["author"]["username"], "alice");
|
||||
assert_eq!(json["reactions"]["👍"], 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Forum article / long-form post — maps to `message_article` table.
|
||||
//!
|
||||
//! Forum articles extend a regular [`Message`] with title, cover image, tags,
|
||||
//! and view/like stats. Rendered as waterfall cards in forum channel views.
|
||||
//! One article per message (1:1), linked via `message_id`.
|
||||
//!
|
||||
//! A message is an article when `message.message_type = "article"`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Extended metadata for a forum article / post.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageArticle {
|
||||
pub id: Uuid,
|
||||
/// FK to `message.id`. UNIQUE constraint ensures 1:1.
|
||||
pub message_id: Uuid,
|
||||
/// Article title (plain text, max 256 chars).
|
||||
pub title: String,
|
||||
/// Short excerpt for card preview (plain text, max 512 chars).
|
||||
pub summary: Option<String>,
|
||||
/// Cover image URL (displayed at the top of the card).
|
||||
pub cover_url: Option<String>,
|
||||
/// Cover image width in pixels (for waterfall layout height calculation).
|
||||
pub cover_width: Option<i32>,
|
||||
/// Cover image height in pixels.
|
||||
pub cover_height: Option<i32>,
|
||||
/// Cover image dominant color (hex, for placeholder while loading).
|
||||
pub cover_color: Option<String>,
|
||||
/// Tag IDs referencing `forum_tag` table, stored as JSON array.
|
||||
pub tags: Option<serde_json::Value>,
|
||||
/// View count (denormalized, incremented on read).
|
||||
pub view_count: i64,
|
||||
/// Reaction / like count (denormalized).
|
||||
pub like_count: i64,
|
||||
/// Bookmark count (denormalized).
|
||||
pub bookmark_count: i64,
|
||||
/// Reply count (denormalized, threads inside the article).
|
||||
pub reply_count: i64,
|
||||
/// Most recent reply message id.
|
||||
pub last_reply_message_id: Option<Uuid>,
|
||||
/// Most recent reply timestamp.
|
||||
pub last_reply_at: Option<DateTime<Utc>>,
|
||||
/// User id of the last replier.
|
||||
pub last_reply_user_id: Option<Uuid>,
|
||||
/// Whether the article is pinned to the top of the forum channel.
|
||||
pub is_pinned_to_top: bool,
|
||||
/// Whether the question has been answered / resolved.
|
||||
pub is_answered: bool,
|
||||
/// Who marked it as answered.
|
||||
pub answered_by: Option<Uuid>,
|
||||
/// When it was marked as answered.
|
||||
pub answered_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Waterfall card view — article enriched with author info + first attachment
|
||||
/// (cover fallback). Returned by forum list APIs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArticleCard {
|
||||
#[serde(flatten)]
|
||||
pub article: MessageArticle,
|
||||
/// Author display info.
|
||||
pub author: super::message::AuthorInfo,
|
||||
/// Resolved tag names (e.g. ["Bug Report", "High Priority"]).
|
||||
pub tag_names: Vec<String>,
|
||||
/// First image attachment URL (fallback when cover_url is NULL).
|
||||
pub first_image_url: Option<String>,
|
||||
/// Whether the current user has bookmarked this article.
|
||||
pub bookmarked: bool,
|
||||
/// Whether the current user has liked this article.
|
||||
pub liked: bool,
|
||||
}
|
||||
|
||||
/// Input payload for creating a new forum article.
|
||||
///
|
||||
/// Sent by the client when composing a forum post.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateArticleInput {
|
||||
pub channel_id: Uuid,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub summary: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub tags: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Input payload for updating an existing forum article.
|
||||
///
|
||||
/// All fields are optional — only provided fields are updated.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateArticleInput {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub cover_color: Option<String>,
|
||||
pub tags: Option<Vec<Uuid>>,
|
||||
}
|
||||
|
||||
/// Sort modes for forum article listing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ArticleSort {
|
||||
/// Most recent activity first.
|
||||
#[default]
|
||||
LatestActivity,
|
||||
/// Newest articles first.
|
||||
Newest,
|
||||
/// Most viewed first.
|
||||
MostViewed,
|
||||
/// Most liked first.
|
||||
MostLiked,
|
||||
/// Pinned articles first, then by latest activity.
|
||||
PinnedFirst,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_article_card_serialize() {
|
||||
let article = MessageArticle {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
title: "Bug Report: Login fails on Safari".into(),
|
||||
summary: Some("Users report that login fails on Safari 18...".into()),
|
||||
cover_url: Some("https://cdn.example.com/covers/bug.png".into()),
|
||||
cover_width: Some(1200),
|
||||
cover_height: Some(630),
|
||||
cover_color: Some("#FF6B6B".into()),
|
||||
tags: Some(serde_json::json!(["01909a", "01909b"])),
|
||||
view_count: 142,
|
||||
like_count: 7,
|
||||
bookmark_count: 3,
|
||||
reply_count: 5,
|
||||
last_reply_message_id: Some(Uuid::now_v7()),
|
||||
last_reply_at: Some(Utc::now()),
|
||||
last_reply_user_id: Some(Uuid::now_v7()),
|
||||
is_pinned_to_top: true,
|
||||
is_answered: false,
|
||||
answered_by: None,
|
||||
answered_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let card = ArticleCard {
|
||||
article,
|
||||
author: super::super::message::AuthorInfo {
|
||||
id: Uuid::now_v7(),
|
||||
username: "alice".into(),
|
||||
display_name: Some("Alice".into()),
|
||||
avatar_url: None,
|
||||
is_bot: false,
|
||||
},
|
||||
tag_names: vec!["Bug Report".into(), "High Priority".into()],
|
||||
first_image_url: None,
|
||||
bookmarked: false,
|
||||
liked: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&card).unwrap();
|
||||
assert_eq!(json["title"], "Bug Report: Login fails on Safari");
|
||||
assert_eq!(json["view_count"], 142);
|
||||
assert_eq!(json["author"]["username"], "alice");
|
||||
assert_eq!(json["tag_names"][0], "Bug Report");
|
||||
assert_eq!(json["is_pinned_to_top"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_article_sort_serialize() {
|
||||
let sort = ArticleSort::MostViewed;
|
||||
let json = serde_json::to_value(sort).unwrap();
|
||||
assert_eq!(json, "most_viewed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_article_input_serialize() {
|
||||
let input = CreateArticleInput {
|
||||
channel_id: Uuid::now_v7(),
|
||||
title: "New Feature Proposal".into(),
|
||||
body: "I'd like to propose...".into(),
|
||||
summary: Some("A proposal for...".into()),
|
||||
cover_url: None,
|
||||
tags: Some(vec![Uuid::now_v7()]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&input).unwrap();
|
||||
assert_eq!(json["title"], "New Feature Proposal");
|
||||
assert_eq!(json["tags"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! File / image attachment on a message — new table `message_attachment`.
|
||||
//!
|
||||
//! Discord-style: each message can have multiple attachments. Files are
|
||||
//! uploaded to S3 first, then the URL is stored here.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A file or image attachment on a message.
|
||||
///
|
||||
/// Maps to the `message_attachment` table. Files are uploaded to object
|
||||
/// storage first, then the URL is stored here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageAttachment {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
/// Original filename as uploaded by the user.
|
||||
pub filename: String,
|
||||
/// MIME type: "image/png", "application/pdf", etc.
|
||||
pub content_type: Option<String>,
|
||||
/// File size in bytes.
|
||||
pub size: i64,
|
||||
/// Public URL (S3 presigned or CDN).
|
||||
pub url: String,
|
||||
/// S3 / object-store key for backend access.
|
||||
pub storage_key: Option<String>,
|
||||
/// Image / video width in pixels.
|
||||
pub width: Option<i32>,
|
||||
/// Image / video height in pixels.
|
||||
pub height: Option<i32>,
|
||||
/// Audio / video duration in seconds.
|
||||
pub duration_secs: Option<f64>,
|
||||
/// Blurred low-res preview for progressive loading (base64 data URI).
|
||||
pub blurhash: Option<String>,
|
||||
/// Whether this attachment should be rendered as a spoiler (hidden until click).
|
||||
pub spoiler: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Lightweight attachment summary for list views.
|
||||
///
|
||||
/// Omits URL and storage_key to reduce payload size.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttachmentSummary {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub content_type: Option<String>,
|
||||
pub size: i64,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub spoiler: bool,
|
||||
}
|
||||
|
||||
impl From<MessageAttachment> for AttachmentSummary {
|
||||
fn from(a: MessageAttachment) -> Self {
|
||||
Self {
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
content_type: a.content_type,
|
||||
size: a.size,
|
||||
width: a.width,
|
||||
height: a.height,
|
||||
spoiler: a.spoiler,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_attachment_conversion() {
|
||||
let att = MessageAttachment {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
filename: "photo.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: 1024,
|
||||
url: "https://cdn.example.com/photo.png".into(),
|
||||
storage_key: None,
|
||||
width: Some(800),
|
||||
height: Some(600),
|
||||
duration_secs: None,
|
||||
blurhash: None,
|
||||
spoiler: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let summary: AttachmentSummary = att.into();
|
||||
assert_eq!(summary.filename, "photo.png");
|
||||
assert_eq!(summary.size, 1024);
|
||||
assert_eq!(summary.width, Some(800));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! User bookmark on a message — maps to `message_bookmark` table.
|
||||
//!
|
||||
//! Similar to browser bookmarks: user saves a message for later reference,
|
||||
//! optionally with a personal note.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A user bookmark on a message for later reference.
|
||||
///
|
||||
/// Maps to the `message_bookmark` table. Similar to browser bookmarks,
|
||||
/// optionally with a personal note.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageBookmark {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Personal note the user attached to this bookmark.
|
||||
pub note: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bookmark_serialize() {
|
||||
let bm = MessageBookmark {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
note: Some("Important reference".into()),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&bm).unwrap();
|
||||
assert_eq!(json["note"], "Important reference");
|
||||
assert!(json["message_id"].is_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Interactive message components — maps to `message_component` table.
|
||||
//!
|
||||
//! Discord-style interactive elements attached to messages: buttons, select
|
||||
//! menus, etc. Each component belongs to a message and has its own layout row.
|
||||
//! Clicking a component emits an interaction event back to the bot/webhook.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// An interactive component attached to a message (button, select menu, etc.).
|
||||
///
|
||||
/// Maps to the `message_component` table. Each component belongs to a message
|
||||
/// and has a layout row/position. User interactions emit callbacks.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageComponent {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
/// Layout row within the message (0-based).
|
||||
pub row: i32,
|
||||
/// Position within the row (0-based).
|
||||
pub position: i32,
|
||||
/// "button" | "select_menu" | "text_input"
|
||||
pub component_type: String,
|
||||
/// Unique identifier sent back in interaction callbacks.
|
||||
pub custom_id: String,
|
||||
/// Display label.
|
||||
pub label: Option<String>,
|
||||
/// Emoji shown on the button (unicode or `:name:id`).
|
||||
pub emoji: Option<String>,
|
||||
/// Button style: "primary" | "secondary" | "success" | "danger" | "link"
|
||||
pub style: Option<String>,
|
||||
/// URL for link-style buttons.
|
||||
pub url: Option<String>,
|
||||
/// Whether the component is disabled.
|
||||
pub disabled: bool,
|
||||
/// Placeholder text for select menus.
|
||||
pub placeholder: Option<String>,
|
||||
/// Min/max selections for select menus.
|
||||
pub min_values: Option<i32>,
|
||||
pub max_values: Option<i32>,
|
||||
/// Options for select menus, stored as JSON array.
|
||||
pub options: Option<serde_json::Value>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentType {
|
||||
#[default]
|
||||
Button,
|
||||
SelectMenu,
|
||||
TextInput,
|
||||
}
|
||||
|
||||
impl ComponentType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Button => "button",
|
||||
Self::SelectMenu => "select_menu",
|
||||
Self::TextInput => "text_input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_component_serialize() {
|
||||
let c = MessageComponent {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
row: 0,
|
||||
position: 0,
|
||||
component_type: ComponentType::Button.as_str().into(),
|
||||
custom_id: "btn_approve".into(),
|
||||
label: Some("Approve".into()),
|
||||
emoji: Some("✅".into()),
|
||||
style: Some("success".into()),
|
||||
url: None,
|
||||
disabled: false,
|
||||
placeholder: None,
|
||||
min_values: None,
|
||||
max_values: None,
|
||||
options: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&c).unwrap();
|
||||
assert_eq!(json["component_type"], "button");
|
||||
assert_eq!(json["style"], "success");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Message drafts — maps to `message_draft` table.
|
||||
//!
|
||||
//! Stores unsent messages so they survive browser refreshes and sync across
|
||||
//! the user's connected devices. One draft per (channel, user, optional thread).
|
||||
//! Drafts are upserted on every keystroke debounce and deleted on send.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A user's unsent message draft in a channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageDraft {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Thread the draft belongs to (NULL = top-level).
|
||||
pub thread_id: Option<Uuid>,
|
||||
/// Message this draft is replying to (NULL = new message).
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
/// Plain text or markdown body.
|
||||
pub body: String,
|
||||
/// Extensible metadata (attachments to be uploaded, etc.).
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Input for upserting a draft (sent from client on debounced keystroke).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DraftUpsertInput {
|
||||
pub channel_id: Uuid,
|
||||
pub thread_id: Option<Uuid>,
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
pub body: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_draft_upsert_input_serialize() {
|
||||
let input = DraftUpsertInput {
|
||||
channel_id: Uuid::now_v7(),
|
||||
thread_id: None,
|
||||
reply_to_message_id: None,
|
||||
body: "hello, this is a draft".to_string(),
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&input).unwrap();
|
||||
assert_eq!(json["body"], "hello, this is a draft");
|
||||
assert!(json["thread_id"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_serialize() {
|
||||
let draft = MessageDraft {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
thread_id: Some(Uuid::now_v7()),
|
||||
reply_to_message_id: None,
|
||||
body: "draft body".to_string(),
|
||||
metadata: Some(serde_json::json!({"pending_attachments": []})),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&draft).unwrap();
|
||||
assert_eq!(json["body"], "draft body");
|
||||
assert!(json["thread_id"].is_string());
|
||||
assert!(json["metadata"]["pending_attachments"].is_array());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Message edit history — maps to `message_edit` table.
|
||||
//!
|
||||
//! Immutable append-only log of every edit to a message.
|
||||
//! Used for audit trails, "edited" indicators with hover-to-see-original,
|
||||
//! and compliance / moderation review.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// One edit record. Stored every time a message body is modified.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageEdit {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
/// Who made the edit (usually the author; can be a moderator).
|
||||
pub edited_by: Uuid,
|
||||
/// Body content before the edit.
|
||||
pub old_body: String,
|
||||
/// Body content after the edit.
|
||||
pub new_body: String,
|
||||
pub edited_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Lightweight summary for the "edited" tooltip (no full body).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditSummary {
|
||||
pub edit_count: i64,
|
||||
pub last_edited_at: Option<DateTime<Utc>>,
|
||||
pub last_edited_by: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_message_edit_serialize() {
|
||||
let edit = MessageEdit {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
edited_by: Uuid::now_v7(),
|
||||
old_body: "before edit".to_string(),
|
||||
new_body: "after edit".to_string(),
|
||||
edited_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&edit).unwrap();
|
||||
assert_eq!(json["old_body"], "before edit");
|
||||
assert_eq!(json["new_body"], "after edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_summary_serialize() {
|
||||
let summary = EditSummary {
|
||||
edit_count: 3,
|
||||
last_edited_at: Some(Utc::now()),
|
||||
last_edited_by: Some(Uuid::now_v7()),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&summary).unwrap();
|
||||
assert_eq!(json["edit_count"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edit_summary_no_edits() {
|
||||
let summary = EditSummary {
|
||||
edit_count: 0,
|
||||
last_edited_at: None,
|
||||
last_edited_by: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&summary).unwrap();
|
||||
assert_eq!(json["edit_count"], 0);
|
||||
assert!(json["last_edited_at"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Rich embed on a message — new table `message_embed` + `message_embed_field`.
|
||||
//!
|
||||
//! Discord-style embeds: link previews, rich cards with title/description/
|
||||
//! thumbnail/image/footer/fields. Generated by the server (link preview)
|
||||
//! or sent by bots/webhooks.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A single embed attached to a message.
|
||||
/// One message can have multiple embeds (e.g. link preview + bot embed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageEmbed {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
|
||||
/// "link" | "article" | "image" | "video" | "rich"
|
||||
pub embed_type: String,
|
||||
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub url: Option<String>,
|
||||
|
||||
/// Embed accent color as integer (Discord format: 0xRRGGBB).
|
||||
pub color: Option<i32>,
|
||||
|
||||
// Media
|
||||
/// Main image URL.
|
||||
pub image_url: Option<String>,
|
||||
pub image_width: Option<i32>,
|
||||
pub image_height: Option<i32>,
|
||||
/// Small thumbnail URL.
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub thumbnail_width: Option<i32>,
|
||||
pub thumbnail_height: Option<i32>,
|
||||
/// Video URL (for video embeds).
|
||||
pub video_url: Option<String>,
|
||||
pub video_width: Option<i32>,
|
||||
pub video_height: Option<i32>,
|
||||
|
||||
// Footer
|
||||
pub author_name: Option<String>,
|
||||
pub author_url: Option<String>,
|
||||
pub author_icon_url: Option<String>,
|
||||
pub footer_text: Option<String>,
|
||||
pub footer_icon_url: Option<String>,
|
||||
|
||||
/// Provider name (e.g. "YouTube", "GitHub").
|
||||
pub provider_name: Option<String>,
|
||||
pub provider_url: Option<String>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A key-value field within an embed (Discord-style field rows).
|
||||
/// Stored in a separate table for flexibility.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageEmbedField {
|
||||
pub id: Uuid,
|
||||
pub embed_id: Uuid,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
/// Whether this field should display inline (side by side with other inline fields).
|
||||
pub inline: bool,
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
/// An embed with its fields resolved in a single structure.
|
||||
///
|
||||
/// Convenience type returned by read APIs, joining embed and fields.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedDetail {
|
||||
#[serde(flatten)]
|
||||
pub embed: MessageEmbed,
|
||||
pub fields: Vec<MessageEmbedField>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_embed_serialize() {
|
||||
let embed = MessageEmbed {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
embed_type: "link".into(),
|
||||
title: Some("Example".into()),
|
||||
description: Some("A link preview".into()),
|
||||
url: Some("https://example.com".into()),
|
||||
color: Some(0x00FF00),
|
||||
image_url: None,
|
||||
image_width: None,
|
||||
image_height: None,
|
||||
thumbnail_url: None,
|
||||
thumbnail_width: None,
|
||||
thumbnail_height: None,
|
||||
video_url: None,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
author_name: None,
|
||||
author_url: None,
|
||||
author_icon_url: None,
|
||||
footer_text: None,
|
||||
footer_icon_url: None,
|
||||
provider_name: Some("GitHub".into()),
|
||||
provider_url: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&embed).unwrap();
|
||||
assert_eq!(json["embed_type"], "link");
|
||||
assert_eq!(json["title"], "Example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embed_detail_serialize() {
|
||||
let embed = MessageEmbed {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
embed_type: "rich".into(),
|
||||
title: Some("Article".into()),
|
||||
description: None,
|
||||
url: None,
|
||||
color: None,
|
||||
image_url: None,
|
||||
image_width: None,
|
||||
image_height: None,
|
||||
thumbnail_url: None,
|
||||
thumbnail_width: None,
|
||||
thumbnail_height: None,
|
||||
video_url: None,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
author_name: None,
|
||||
author_url: None,
|
||||
author_icon_url: None,
|
||||
footer_text: None,
|
||||
footer_icon_url: None,
|
||||
provider_name: None,
|
||||
provider_url: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let field = MessageEmbedField {
|
||||
id: Uuid::now_v7(),
|
||||
embed_id: embed.id,
|
||||
name: "Key".into(),
|
||||
value: "Value".into(),
|
||||
inline: true,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
let detail = EmbedDetail {
|
||||
embed,
|
||||
fields: vec![field],
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&detail).unwrap();
|
||||
assert_eq!(json["embed_type"], "rich");
|
||||
assert!(json["fields"].is_array());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Message forwarding trail — maps to `message_forward` table.
|
||||
//!
|
||||
//! When a user forwards a message from one channel to another, this table
|
||||
//! records the provenance. The forwarded message is a new message with a
|
||||
//! copy of the original body, linked back via `source_message_id`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A forwarded message linking the copy to the original.
|
||||
///
|
||||
/// Maps to the `message_forward` table. Records provenance when a user
|
||||
/// forwards a message from one channel to another.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageForward {
|
||||
pub id: Uuid,
|
||||
/// The new (forwarded) message.
|
||||
pub message_id: Uuid,
|
||||
/// The original message being forwarded.
|
||||
pub source_message_id: Uuid,
|
||||
/// The channel the original message came from.
|
||||
pub source_channel_id: Uuid,
|
||||
/// Who forwarded the message.
|
||||
pub forwarded_by: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_forward_serialize() {
|
||||
let f = MessageForward {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
source_message_id: Uuid::now_v7(),
|
||||
source_channel_id: Uuid::now_v7(),
|
||||
forwarded_by: Uuid::now_v7(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&f).unwrap();
|
||||
assert!(json["source_message_id"].is_string());
|
||||
assert!(json["source_channel_id"].is_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! @mention tracking — maps to `message_mention` table.
|
||||
//!
|
||||
//! Parsed from message body on send. Used for notification and
|
||||
//! "mentions" feed.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// An @mention of a user in a message.
|
||||
///
|
||||
/// Maps to the `message_mention` table. Parsed from message body on send
|
||||
/// and used for notifications and the mentions feed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageMention {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub mentioned_user_id: Uuid,
|
||||
pub mentioned_by: Uuid,
|
||||
/// When the mentioned user read the notification.
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Parse @username mentions from a message body.
|
||||
/// Returns unique usernames (without the `@` prefix).
|
||||
///
|
||||
/// Matches `@word` where word is `[a-zA-Z0-9_-]+`, min 2 chars.
|
||||
/// Ignores `@@` (escaped) and mentions inside code spans/blocks.
|
||||
pub fn parse_mentions(body: &str) -> Vec<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut mentions = Vec::new();
|
||||
|
||||
// Simple state machine: skip content inside backtick spans.
|
||||
let mut in_code = false;
|
||||
let bytes = body.as_bytes();
|
||||
let mut i = 0;
|
||||
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'`' {
|
||||
in_code = !in_code;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !in_code && bytes[i] == b'@' {
|
||||
// Skip escaped @@
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'@' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = i + 1;
|
||||
let mut end = start;
|
||||
while end < bytes.len()
|
||||
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_' || bytes[end] == b'-')
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let len = end - start;
|
||||
if len >= 2 {
|
||||
let name = body[start..end].to_lowercase();
|
||||
if seen.insert(name.clone()) {
|
||||
mentions.push(name);
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
mentions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_basic() {
|
||||
let m = parse_mentions("hey @alice, cc @bob");
|
||||
assert_eq!(m, vec!["alice".to_string(), "bob".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_dedup() {
|
||||
let m = parse_mentions("@alice said hi to @alice");
|
||||
assert_eq!(m, vec!["alice".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_case_insensitive() {
|
||||
let m = parse_mentions("@Alice and @ALICE");
|
||||
assert_eq!(m, vec!["alice".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_skip_code_span() {
|
||||
let m = parse_mentions("use `@here` to notify @alice");
|
||||
assert_eq!(m, vec!["alice".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_skip_escaped() {
|
||||
let m = parse_mentions("@@alice is not a mention, but @bob is");
|
||||
assert_eq!(m, vec!["bob".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_short_name_ignored() {
|
||||
let m = parse_mentions("@a is too short, @ab is ok");
|
||||
assert_eq!(m, vec!["ab".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mentions_empty() {
|
||||
assert!(parse_mentions("no mentions here").is_empty());
|
||||
assert!(parse_mentions("").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Notification delivery tracking — maps to `message_notification` table.
|
||||
//!
|
||||
//! Records when a message triggers a notification for a user (mention, reply,
|
||||
//! thread activity, etc.) and tracks the delivery/read lifecycle.
|
||||
//! Separate from `message_mention` which only covers @mentions.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A notification triggered for a user by a message.
|
||||
///
|
||||
/// Maps to the `message_notification` table. Records when a message triggers
|
||||
/// a notification (mention, reply, thread activity) and tracks delivery/read.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageNotification {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// "mention" | "reply" | "thread" | "watch"
|
||||
pub reason: String,
|
||||
/// "pending" | "delivered" | "read" | "dismissed"
|
||||
pub status: String,
|
||||
/// Channel of delivery: "push" | "email" | "in_app"
|
||||
pub delivery_channel: Option<String>,
|
||||
pub delivered_at: Option<DateTime<Utc>>,
|
||||
pub read_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationReason {
|
||||
#[default]
|
||||
Mention,
|
||||
Reply,
|
||||
Thread,
|
||||
Watch,
|
||||
}
|
||||
|
||||
impl NotificationReason {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Mention => "mention",
|
||||
Self::Reply => "reply",
|
||||
Self::Thread => "thread",
|
||||
Self::Watch => "watch",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationStatus {
|
||||
#[default]
|
||||
Pending,
|
||||
Delivered,
|
||||
Read,
|
||||
Dismissed,
|
||||
}
|
||||
|
||||
impl NotificationStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Delivered => "delivered",
|
||||
Self::Read => "read",
|
||||
Self::Dismissed => "dismissed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_notification_serialize() {
|
||||
let n = MessageNotification {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
reason: NotificationReason::Mention.as_str().into(),
|
||||
status: NotificationStatus::Pending.as_str().into(),
|
||||
delivery_channel: Some("push".into()),
|
||||
delivered_at: None,
|
||||
read_at: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&n).unwrap();
|
||||
assert_eq!(json["reason"], "mention");
|
||||
assert_eq!(json["status"], "pending");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Pinned message management — maps to `message_pin` table.
|
||||
//!
|
||||
//! A channel can have multiple pinned messages with explicit ordering.
|
||||
//! Unlike the `message.pinned` boolean (which just marks the row),
|
||||
//! this table tracks *who* pinned, *when*, and the display *position*.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// One pinned message entry. Ordered by `position` ascending.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessagePin {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
/// Who pinned this message.
|
||||
pub pinned_by: Uuid,
|
||||
/// Display position in the pinned list (0 = top).
|
||||
pub position: i32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Summary view returned by list-pins APIs (joined with message content).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PinDetail {
|
||||
#[serde(flatten)]
|
||||
pub pin: MessagePin,
|
||||
pub message_body: String,
|
||||
pub message_author_id: Uuid,
|
||||
pub message_created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pin_detail_serialize() {
|
||||
let pin = MessagePin {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
pinned_by: Uuid::now_v7(),
|
||||
position: 0,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let detail = PinDetail {
|
||||
pin,
|
||||
message_body: "important announcement".to_string(),
|
||||
message_author_id: Uuid::now_v7(),
|
||||
message_created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&detail).unwrap();
|
||||
assert_eq!(json["position"], 0);
|
||||
assert_eq!(json["message_body"], "important announcement");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Poll on a message — new tables `message_poll`, `message_poll_option`,
|
||||
//! `message_poll_vote`.
|
||||
//!
|
||||
//! Discord-style polls: attached to a message, with multiple options,
|
||||
//! optional multi-vote, and an expiry time.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The poll itself (one per message, optional).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessagePoll {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
/// The question displayed to voters.
|
||||
pub question: String,
|
||||
/// Whether users can select multiple options.
|
||||
pub allow_multiselect: bool,
|
||||
/// Maximum number of options a user can select (NULL = unlimited when multiselect).
|
||||
pub max_selections: Option<i32>,
|
||||
/// When voting closes (NULL = no expiry).
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Total number of votes cast (denormalized for fast reads).
|
||||
pub total_votes: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A single selectable option within a poll.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessagePollOption {
|
||||
pub id: Uuid,
|
||||
pub poll_id: Uuid,
|
||||
/// Display text for this option.
|
||||
pub text: String,
|
||||
/// Optional emoji prefix (Discord-style).
|
||||
pub emoji: Option<String>,
|
||||
/// Number of votes this option received (denormalized).
|
||||
pub vote_count: i64,
|
||||
/// Display order.
|
||||
pub position: i32,
|
||||
}
|
||||
|
||||
/// A single vote cast by a user.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessagePollVote {
|
||||
pub id: Uuid,
|
||||
pub poll_id: Uuid,
|
||||
pub option_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Aggregated poll results for API responses.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PollResult {
|
||||
pub poll: MessagePoll,
|
||||
pub options: Vec<PollOptionResult>,
|
||||
/// Which options the current user voted for (empty if not voted).
|
||||
pub my_votes: Vec<Uuid>,
|
||||
/// Whether the poll has expired.
|
||||
pub is_expired: bool,
|
||||
}
|
||||
|
||||
/// Option with its vote count and percentage.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PollOptionResult {
|
||||
#[serde(flatten)]
|
||||
pub option: MessagePollOption,
|
||||
/// Percentage of total votes (0.0–100.0), rounded to 1 decimal.
|
||||
pub percentage: f64,
|
||||
}
|
||||
|
||||
impl PollResult {
|
||||
/// Compute percentages from total_votes.
|
||||
pub fn from_poll(
|
||||
poll: MessagePoll,
|
||||
options: Vec<MessagePollOption>,
|
||||
my_votes: Vec<Uuid>,
|
||||
) -> Self {
|
||||
let total = poll.total_votes.max(1) as f64;
|
||||
let now = Utc::now();
|
||||
let is_expired = poll.expires_at.is_some_and(|exp| now >= exp);
|
||||
|
||||
let options = options
|
||||
.into_iter()
|
||||
.map(|opt| {
|
||||
let pct = if poll.total_votes > 0 {
|
||||
(opt.vote_count as f64 / total * 100.0 * 10.0).round() / 10.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
PollOptionResult {
|
||||
option: opt,
|
||||
percentage: pct,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
poll,
|
||||
options,
|
||||
my_votes,
|
||||
is_expired,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_poll_result_percentages() {
|
||||
let poll = MessagePoll {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
question: "Best language?".to_string(),
|
||||
allow_multiselect: false,
|
||||
max_selections: None,
|
||||
expires_at: None,
|
||||
total_votes: 10,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let options = vec![
|
||||
MessagePollOption {
|
||||
id: Uuid::now_v7(),
|
||||
poll_id: poll.id,
|
||||
text: "Rust".to_string(),
|
||||
emoji: None,
|
||||
vote_count: 7,
|
||||
position: 0,
|
||||
},
|
||||
MessagePollOption {
|
||||
id: Uuid::now_v7(),
|
||||
poll_id: poll.id,
|
||||
text: "Go".to_string(),
|
||||
emoji: None,
|
||||
vote_count: 3,
|
||||
position: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let result = PollResult::from_poll(poll, options, vec![]);
|
||||
assert!(!result.is_expired);
|
||||
assert_eq!(result.options[0].percentage, 70.0);
|
||||
assert_eq!(result.options[1].percentage, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poll_result_zero_votes() {
|
||||
let poll = MessagePoll {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
question: "Empty poll".to_string(),
|
||||
allow_multiselect: false,
|
||||
max_selections: None,
|
||||
expires_at: None,
|
||||
total_votes: 0,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let result = PollResult::from_poll(poll, vec![], vec![]);
|
||||
assert!(result.options.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Emoji reaction on a message — maps to `message_reaction` table.
|
||||
//!
|
||||
//! One row per (user, message, emoji). `content` stores the emoji string
|
||||
//! (Unicode emoji or custom `:name:id` format like Discord).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A single emoji reaction on a message by a user.
|
||||
///
|
||||
/// Maps to the `message_reaction` table. One row per (user, message, emoji).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageReaction {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Emoji string: "👍", "🎉", or custom ":appks:01909a..."
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Aggregated reaction count for a single emoji on a message.
|
||||
/// Returned in [`MessageDetail::reactions`] and list APIs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReactionCount {
|
||||
pub content: String,
|
||||
pub count: i64,
|
||||
/// Whether the current user reacted with this emoji.
|
||||
pub me: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_reaction_serialize() {
|
||||
let reaction = MessageReaction {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
content: "👍".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&reaction).unwrap();
|
||||
assert_eq!(json["content"], "👍");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reaction_count_serialize() {
|
||||
let count = ReactionCount {
|
||||
content: "🎉".into(),
|
||||
count: 3,
|
||||
me: true,
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&count).unwrap();
|
||||
assert_eq!(json["content"], "🎉");
|
||||
assert_eq!(json["count"], 3);
|
||||
assert_eq!(json["me"], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Per-user read state — maps to `message_read_state` table.
|
||||
//!
|
||||
//! Tracks the last message each user has read in each channel.
|
||||
//! Used for unread badges, "mark as read", and notification suppression.
|
||||
//! One row per (channel_id, user_id), upserted on each read.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A user's read progress in one channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageReadState {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// The last message id the user has read (cursor).
|
||||
pub last_read_message_id: Option<Uuid>,
|
||||
/// When the user last opened / scrolled through this channel.
|
||||
pub last_read_at: Option<DateTime<Utc>>,
|
||||
/// Total unread message count (denormalized for fast badge display).
|
||||
pub unread_count: i64,
|
||||
/// Total unread @mentions for this channel (denormalized).
|
||||
pub unread_mentions: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Summary of a user's read state for the client-side channel list.
|
||||
///
|
||||
/// Includes unread badge count and mention count for a single channel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadStateSummary {
|
||||
pub channel_id: Uuid,
|
||||
pub unread_count: i64,
|
||||
pub unread_mentions: i64,
|
||||
pub has_unread: bool,
|
||||
}
|
||||
|
||||
impl From<MessageReadState> for ReadStateSummary {
|
||||
fn from(s: MessageReadState) -> Self {
|
||||
Self {
|
||||
channel_id: s.channel_id,
|
||||
unread_count: s.unread_count,
|
||||
unread_mentions: s.unread_mentions,
|
||||
has_unread: s.unread_count > 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_read_state_summary_conversion() {
|
||||
let state = MessageReadState {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
last_read_message_id: None,
|
||||
last_read_at: None,
|
||||
unread_count: 5,
|
||||
unread_mentions: 2,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let summary: ReadStateSummary = state.into();
|
||||
assert!(summary.has_unread);
|
||||
assert_eq!(summary.unread_count, 5);
|
||||
assert_eq!(summary.unread_mentions, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_state_summary_no_unread() {
|
||||
let state = MessageReadState {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
last_read_message_id: None,
|
||||
last_read_at: None,
|
||||
unread_count: 0,
|
||||
unread_mentions: 0,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let summary: ReadStateSummary = state.into();
|
||||
assert!(!summary.has_unread);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Scheduled messages — maps to `message_scheduled` table.
|
||||
//!
|
||||
//! A message that the user has composed but wants to send at a future time.
|
||||
//! A background job picks up rows where `scheduled_at <= now()` and dispatches
|
||||
//! them through the normal send path.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A message scheduled to be sent at a future time.
|
||||
///
|
||||
/// Maps to the `message_scheduled` table. A background job picks up rows
|
||||
/// where `scheduled_at <= now()` and dispatches them through the normal send path.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageScheduled {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
pub thread_id: Option<Uuid>,
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
pub body: String,
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// When the message should be sent.
|
||||
pub scheduled_at: DateTime<Utc>,
|
||||
/// "pending" | "sent" | "cancelled" | "failed"
|
||||
pub status: String,
|
||||
/// Set after the message is dispatched; points to the sent `message.id`.
|
||||
pub sent_message_id: Option<Uuid>,
|
||||
/// Error message if dispatch failed.
|
||||
pub error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScheduledStatus {
|
||||
#[default]
|
||||
Pending,
|
||||
Sent,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ScheduledStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Sent => "sent",
|
||||
Self::Cancelled => "cancelled",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scheduled_serialize() {
|
||||
let s = MessageScheduled {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
author_id: Uuid::now_v7(),
|
||||
thread_id: None,
|
||||
reply_to_message_id: None,
|
||||
body: "Good morning everyone!".into(),
|
||||
metadata: None,
|
||||
scheduled_at: Utc::now(),
|
||||
status: ScheduledStatus::Pending.as_str().into(),
|
||||
sent_message_id: None,
|
||||
error: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(json["status"], "pending");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Sticker attachment on a message — maps to `message_sticker` table.
|
||||
//!
|
||||
//! Large sticker images sent in messages, distinct from emoji reactions.
|
||||
//! Stickers are either workspace-level (custom, defined in appks) or
|
||||
//! system-level (built-in sticker packs).
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A sticker attached to a message.
|
||||
///
|
||||
/// Maps to the `message_sticker` table. Stickers are larger than emoji
|
||||
/// and can be workspace-level (custom) or system-level (built-in packs).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageSticker {
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
/// References a sticker defined in appks (workspace or system sticker).
|
||||
pub sticker_id: Uuid,
|
||||
/// Sticker name at time of send (snapshot for history).
|
||||
pub name: String,
|
||||
/// Image URL (snapshot).
|
||||
pub image_url: String,
|
||||
/// "png" | "apng" | "lottie"
|
||||
pub format_type: String,
|
||||
/// Pack name (e.g. "Wumpus" or workspace name).
|
||||
pub pack_name: Option<String>,
|
||||
/// Search tags for discovery.
|
||||
pub tags: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sticker_serialize() {
|
||||
let s = MessageSticker {
|
||||
id: Uuid::now_v7(),
|
||||
message_id: Uuid::now_v7(),
|
||||
sticker_id: Uuid::now_v7(),
|
||||
name: "Hype!".into(),
|
||||
image_url: "https://cdn.example.com/stickers/hype.png".into(),
|
||||
format_type: "png".into(),
|
||||
pack_name: Some("Wumpus".into()),
|
||||
tags: Some("excited,hype".into()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&s).unwrap();
|
||||
assert_eq!(json["name"], "Hype!");
|
||||
assert_eq!(json["format_type"], "png");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Thread metadata — maps to `message_thread` table.
|
||||
//!
|
||||
//! A thread is anchored by a root message. Reply messages in the same thread
|
||||
//! set `message.thread_id = message_thread.id`.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A message thread anchored by a root message.
|
||||
///
|
||||
/// Maps to the `message_thread` table. Reply messages set
|
||||
/// `message.thread_id = message_thread.id` to join the thread.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageThread {
|
||||
pub id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
/// The first message that started this thread.
|
||||
pub root_message_id: Uuid,
|
||||
pub created_by: Uuid,
|
||||
pub replies_count: i64,
|
||||
pub participants_count: i64,
|
||||
pub last_reply_message_id: Option<Uuid>,
|
||||
pub last_reply_at: Option<DateTime<Utc>>,
|
||||
/// Forum-style: mark thread as resolved / answered.
|
||||
pub resolved: bool,
|
||||
pub resolved_by: Option<Uuid>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_thread_serialize() {
|
||||
let thread = MessageThread {
|
||||
id: Uuid::now_v7(),
|
||||
channel_id: Uuid::now_v7(),
|
||||
root_message_id: Uuid::now_v7(),
|
||||
created_by: Uuid::now_v7(),
|
||||
replies_count: 5,
|
||||
participants_count: 3,
|
||||
last_reply_message_id: None,
|
||||
last_reply_at: None,
|
||||
resolved: false,
|
||||
resolved_by: None,
|
||||
resolved_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&thread).unwrap();
|
||||
assert_eq!(json["replies_count"], 5);
|
||||
assert_eq!(json["resolved"], false);
|
||||
assert!(json["id"].is_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Thread participant membership — maps to `message_thread_participant` table.
|
||||
//!
|
||||
//! Tracks which users are part of a thread. A user becomes a participant when
|
||||
//! they reply in a thread, get @mentioned, or are explicitly added.
|
||||
//! Without this table, `message_thread.participants_count` is un-verifiable.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A user participating in a thread.
|
||||
///
|
||||
/// Maps to the `message_thread_participant` table. Users become participants
|
||||
/// when they reply, get @mentioned, or are explicitly added.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct MessageThreadParticipant {
|
||||
pub id: Uuid,
|
||||
pub thread_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// How the user joined the thread.
|
||||
pub joined_reason: Option<String>,
|
||||
pub last_read_message_id: Option<Uuid>,
|
||||
pub last_read_at: Option<DateTime<Utc>>,
|
||||
pub joined_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JoinReason {
|
||||
/// User sent a reply in the thread.
|
||||
#[default]
|
||||
Reply,
|
||||
/// User was @mentioned in a thread message.
|
||||
Mentioned,
|
||||
/// User was explicitly added by another participant.
|
||||
Added,
|
||||
/// User joined the thread themselves.
|
||||
Joined,
|
||||
}
|
||||
|
||||
impl JoinReason {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Reply => "reply",
|
||||
Self::Mentioned => "mentioned",
|
||||
Self::Added => "added",
|
||||
Self::Joined => "joined",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_participant_serialize() {
|
||||
let p = MessageThreadParticipant {
|
||||
id: Uuid::now_v7(),
|
||||
thread_id: Uuid::now_v7(),
|
||||
user_id: Uuid::now_v7(),
|
||||
joined_reason: Some(JoinReason::Reply.as_str().into()),
|
||||
last_read_message_id: None,
|
||||
last_read_at: None,
|
||||
joined_at: Utc::now(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(json["joined_reason"], "reply");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
pub mod message;
|
||||
pub mod message_article;
|
||||
pub mod message_attachment;
|
||||
pub mod message_bookmark;
|
||||
pub mod message_component;
|
||||
pub mod message_draft;
|
||||
pub mod message_edit;
|
||||
pub mod message_embed;
|
||||
pub mod message_forward;
|
||||
pub mod message_mention;
|
||||
pub mod message_notification;
|
||||
pub mod message_pin;
|
||||
pub mod message_poll;
|
||||
pub mod message_reaction;
|
||||
pub mod message_read_state;
|
||||
pub mod message_scheduled;
|
||||
pub mod message_sticker;
|
||||
pub mod message_thread;
|
||||
pub mod message_thread_participant;
|
||||
|
||||
pub use message::{Message, MessageDetail, MessageType};
|
||||
pub use message_article::{
|
||||
ArticleCard, ArticleSort, CreateArticleInput, MessageArticle, UpdateArticleInput,
|
||||
};
|
||||
pub use message_attachment::{AttachmentSummary, MessageAttachment};
|
||||
pub use message_bookmark::MessageBookmark;
|
||||
pub use message_component::{ComponentType, MessageComponent};
|
||||
pub use message_draft::{DraftUpsertInput, MessageDraft};
|
||||
pub use message_edit::{EditSummary, MessageEdit};
|
||||
pub use message_embed::{EmbedDetail, MessageEmbed, MessageEmbedField};
|
||||
pub use message_forward::MessageForward;
|
||||
pub use message_mention::MessageMention;
|
||||
pub use message_notification::{MessageNotification, NotificationReason, NotificationStatus};
|
||||
pub use message_pin::{MessagePin, PinDetail};
|
||||
pub use message_poll::{
|
||||
MessagePoll, MessagePollOption, MessagePollVote, PollOptionResult, PollResult,
|
||||
};
|
||||
pub use message_reaction::{MessageReaction, ReactionCount};
|
||||
pub use message_read_state::{MessageReadState, ReadStateSummary};
|
||||
pub use message_scheduled::{MessageScheduled, ScheduledStatus};
|
||||
pub use message_sticker::MessageSticker;
|
||||
pub use message_thread::MessageThread;
|
||||
pub use message_thread_participant::{JoinReason, MessageThreadParticipant};
|
||||
Reference in New Issue
Block a user