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,263 @@
|
||||
//! Forum article CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! Articles extend regular messages with forum-specific metadata (title, cover,
|
||||
//! view/like stats, tags). Rendered as waterfall cards in forum channels.
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message::AuthorInfo;
|
||||
use crate::models::message_article::{ArticleCard, ArticleSort, MessageArticle};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
use super::pagination::{CursorPage, clamp_limit};
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create an article record linked to an existing message.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_article(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
title: &str,
|
||||
summary: Option<&str>,
|
||||
cover_url: Option<&str>,
|
||||
cover_width: Option<i32>,
|
||||
cover_height: Option<i32>,
|
||||
cover_color: Option<&str>,
|
||||
tags: Option<&serde_json::Value>,
|
||||
) -> ImksResult<MessageArticle> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query_as::<_, MessageArticle>(
|
||||
r#"
|
||||
INSERT INTO message_article (
|
||||
id, message_id, title, summary, cover_url, cover_width, cover_height,
|
||||
cover_color, tags, view_count, like_count, bookmark_count, reply_count,
|
||||
is_pinned_to_top, is_answered, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, 0, 0, 0, FALSE, FALSE, $10, $10)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(message_id)
|
||||
.bind(title)
|
||||
.bind(summary)
|
||||
.bind(cover_url)
|
||||
.bind(cover_width)
|
||||
.bind(cover_height)
|
||||
.bind(cover_color)
|
||||
.bind(tags)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Update an existing article's metadata. Does NOT update the message body.
|
||||
pub async fn update_article(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
title: Option<&str>,
|
||||
summary: Option<&str>,
|
||||
cover_url: Option<&str>,
|
||||
cover_color: Option<&str>,
|
||||
tags: Option<&serde_json::Value>,
|
||||
) -> ImksResult<Option<MessageArticle>> {
|
||||
let now = Utc::now();
|
||||
sqlx::query_as::<_, MessageArticle>(
|
||||
r#"
|
||||
UPDATE message_article
|
||||
SET title = COALESCE($1, title),
|
||||
summary = COALESCE($2, summary),
|
||||
cover_url = COALESCE($3, cover_url),
|
||||
cover_color = COALESCE($4, cover_color),
|
||||
tags = COALESCE($5, tags),
|
||||
updated_at = $6
|
||||
WHERE message_id = $7
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(title)
|
||||
.bind(summary)
|
||||
.bind(cover_url)
|
||||
.bind(cover_color)
|
||||
.bind(tags)
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get an article by its message_id.
|
||||
pub async fn get_article(&self, message_id: Uuid) -> ImksResult<Option<MessageArticle>> {
|
||||
sqlx::query_as::<_, MessageArticle>("SELECT * FROM message_article WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// List articles in a forum channel with id-based cursor pagination.
|
||||
pub async fn list_articles(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
sort: ArticleSort,
|
||||
before: Option<(i64, Uuid)>,
|
||||
limit: Option<i64>,
|
||||
) -> ImksResult<CursorPage<ArticleCard>> {
|
||||
let effective_limit = clamp_limit(limit);
|
||||
let fetch_limit = effective_limit + 1;
|
||||
let cursor_id = before.map(|(_, id)| id);
|
||||
|
||||
let order_by = match sort {
|
||||
ArticleSort::LatestActivity => "a.last_reply_at DESC NULLS LAST, m.id DESC",
|
||||
ArticleSort::Newest => "m.id DESC",
|
||||
ArticleSort::MostViewed => "a.view_count DESC, m.id DESC",
|
||||
ArticleSort::MostLiked => "a.like_count DESC, m.id DESC",
|
||||
ArticleSort::PinnedFirst => {
|
||||
"a.is_pinned_to_top DESC, a.last_reply_at DESC NULLS LAST, m.id DESC"
|
||||
}
|
||||
};
|
||||
|
||||
let query = if cursor_id.is_some() {
|
||||
format!(
|
||||
r#"
|
||||
SELECT a.*, m.author_id,
|
||||
(
|
||||
SELECT att.url
|
||||
FROM message_attachment att
|
||||
WHERE att.message_id = a.message_id
|
||||
AND att.content_type LIKE 'image/%'
|
||||
ORDER BY att.created_at
|
||||
LIMIT 1
|
||||
) AS first_image_url
|
||||
FROM message_article a
|
||||
JOIN message m ON m.id = a.message_id
|
||||
WHERE m.channel_id = $1
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.id < $2
|
||||
ORDER BY {order_by}
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"
|
||||
SELECT a.*, m.author_id,
|
||||
(
|
||||
SELECT att.url
|
||||
FROM message_attachment att
|
||||
WHERE att.message_id = a.message_id
|
||||
AND att.content_type LIKE 'image/%'
|
||||
ORDER BY att.created_at
|
||||
LIMIT 1
|
||||
) AS first_image_url
|
||||
FROM message_article a
|
||||
JOIN message m ON m.id = a.message_id
|
||||
WHERE m.channel_id = $1
|
||||
AND m.deleted_at IS NULL
|
||||
ORDER BY {order_by}
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
};
|
||||
|
||||
let rows = if let Some(cursor) = cursor_id {
|
||||
sqlx::query(sqlx::AssertSqlSafe(query.as_str()))
|
||||
.bind(channel_id)
|
||||
.bind(cursor)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(sqlx::AssertSqlSafe(query.as_str()))
|
||||
.bind(channel_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
};
|
||||
|
||||
// Convert raw rows to MessageArticle
|
||||
let articles: Vec<MessageArticle> = rows
|
||||
.iter()
|
||||
.map(|r| MessageArticle {
|
||||
id: r.get("id"),
|
||||
message_id: r.get("message_id"),
|
||||
title: r.get("title"),
|
||||
summary: r.get("summary"),
|
||||
cover_url: r.get("cover_url"),
|
||||
cover_width: r.get("cover_width"),
|
||||
cover_height: r.get("cover_height"),
|
||||
cover_color: r.get("cover_color"),
|
||||
tags: r.get("tags"),
|
||||
view_count: r.get("view_count"),
|
||||
like_count: r.get("like_count"),
|
||||
bookmark_count: r.get("bookmark_count"),
|
||||
reply_count: r.get("reply_count"),
|
||||
last_reply_message_id: r.get("last_reply_message_id"),
|
||||
last_reply_at: r.get("last_reply_at"),
|
||||
last_reply_user_id: r.get("last_reply_user_id"),
|
||||
is_pinned_to_top: r.get("is_pinned_to_top"),
|
||||
is_answered: r.get("is_answered"),
|
||||
answered_by: r.get("answered_by"),
|
||||
answered_at: r.get("answered_at"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let has_more = articles.len() > effective_limit as usize;
|
||||
let items: Vec<MessageArticle> = articles
|
||||
.into_iter()
|
||||
.take(effective_limit as usize)
|
||||
.collect();
|
||||
|
||||
let next_cursor = if has_more {
|
||||
items.last().map(|a| a.message_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cards: Vec<ArticleCard> = items
|
||||
.into_iter()
|
||||
.zip(rows.iter())
|
||||
.map(|(article, row)| {
|
||||
let author_id: Uuid = row.get("author_id");
|
||||
ArticleCard {
|
||||
article,
|
||||
author: AuthorInfo {
|
||||
id: author_id,
|
||||
username: author_id.to_string(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
is_bot: false,
|
||||
},
|
||||
tag_names: Vec::new(),
|
||||
first_image_url: row.get("first_image_url"),
|
||||
bookmarked: false,
|
||||
liked: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(CursorPage {
|
||||
items: cards,
|
||||
next_cursor,
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
|
||||
/// Increment the view count for an article.
|
||||
pub async fn increment_article_view(&self, message_id: Uuid) -> ImksResult<()> {
|
||||
sqlx::query("UPDATE message_article SET view_count = view_count + 1 WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Attachment CRUD operations on `MessageRepo`.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_attachment::{AttachmentSummary, MessageAttachment};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create a single attachment record.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_attachment(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
filename: &str,
|
||||
content_type: Option<&str>,
|
||||
size: i64,
|
||||
url: &str,
|
||||
storage_key: Option<&str>,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
spoiler: bool,
|
||||
) -> ImksResult<MessageAttachment> {
|
||||
let id = Uuid::now_v7();
|
||||
|
||||
sqlx::query_as::<_, MessageAttachment>(
|
||||
r#"
|
||||
INSERT INTO message_attachment (
|
||||
id, message_id, filename, content_type, size, url,
|
||||
storage_key, width, height, spoiler
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(message_id)
|
||||
.bind(filename)
|
||||
.bind(content_type)
|
||||
.bind(size)
|
||||
.bind(url)
|
||||
.bind(storage_key)
|
||||
.bind(width)
|
||||
.bind(height)
|
||||
.bind(spoiler)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get all attachments for a message.
|
||||
pub async fn get_attachments(&self, message_id: Uuid) -> ImksResult<Vec<MessageAttachment>> {
|
||||
sqlx::query_as::<_, MessageAttachment>(
|
||||
"SELECT * FROM message_attachment WHERE message_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get lightweight attachment summaries (no URLs/storage keys).
|
||||
pub async fn get_attachment_summaries(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
) -> ImksResult<Vec<AttachmentSummary>> {
|
||||
let attachments = self.get_attachments(message_id).await?;
|
||||
Ok(attachments
|
||||
.into_iter()
|
||||
.map(AttachmentSummary::from)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Delete a single attachment.
|
||||
pub async fn delete_attachment(&self, attachment_id: Uuid) -> ImksResult<bool> {
|
||||
let result = sqlx::query("DELETE FROM message_attachment WHERE id = $1")
|
||||
.bind(attachment_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Bookmark CRUD operations on `MessageRepo`.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_bookmark::MessageBookmark;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
use super::pagination::{CursorPage, clamp_limit};
|
||||
|
||||
impl MessageRepo {
|
||||
/// Add a bookmark for a message.
|
||||
pub async fn add_bookmark(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
note: Option<&str>,
|
||||
) -> ImksResult<MessageBookmark> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query_as::<_, MessageBookmark>(
|
||||
r#"
|
||||
INSERT INTO message_bookmark (id, message_id, channel_id, user_id, note, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
ON CONFLICT (user_id, message_id) DO UPDATE SET note = EXCLUDED.note, updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(note)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Remove a bookmark.
|
||||
pub async fn remove_bookmark(&self, message_id: Uuid, user_id: Uuid) -> ImksResult<bool> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM message_bookmark WHERE message_id = $1 AND user_id = $2")
|
||||
.bind(message_id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Check if a user has bookmarked a message.
|
||||
pub async fn is_bookmarked(&self, message_id: Uuid, user_id: Uuid) -> ImksResult<bool> {
|
||||
let exists: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT id FROM message_bookmark WHERE message_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
/// List a user's bookmarks with cursor-based pagination (newest first).
|
||||
pub async fn list_bookmarks(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ImksResult<CursorPage<MessageBookmark>> {
|
||||
let effective_limit = clamp_limit(limit);
|
||||
let fetch_limit = effective_limit + 1;
|
||||
|
||||
let rows = match before {
|
||||
Some(cursor) => {
|
||||
sqlx::query_as::<_, MessageBookmark>(
|
||||
r#"
|
||||
SELECT * FROM message_bookmark
|
||||
WHERE user_id = $1 AND id < $2
|
||||
ORDER BY id DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(cursor)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, MessageBookmark>(
|
||||
r#"
|
||||
SELECT * FROM message_bookmark
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CursorPage::from_raw(rows, effective_limit, |b| b.id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Interactive component CRUD operations on `MessageRepo`.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_component::MessageComponent;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create an interactive component (button/select menu) on a message.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_component(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
component_type: &str,
|
||||
custom_id: &str,
|
||||
label: Option<&str>,
|
||||
emoji: Option<&str>,
|
||||
style: Option<&str>,
|
||||
url: Option<&str>,
|
||||
disabled: bool,
|
||||
row: i32,
|
||||
position: i32,
|
||||
) -> ImksResult<MessageComponent> {
|
||||
sqlx::query_as::<_, MessageComponent>(
|
||||
r#"
|
||||
INSERT INTO message_component (
|
||||
id, message_id, row, position, component_type, custom_id,
|
||||
label, emoji, style, url, disabled
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(row)
|
||||
.bind(position)
|
||||
.bind(component_type)
|
||||
.bind(custom_id)
|
||||
.bind(label)
|
||||
.bind(emoji)
|
||||
.bind(style)
|
||||
.bind(url)
|
||||
.bind(disabled)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get all components on a message, ordered by layout.
|
||||
pub async fn get_components(&self, message_id: Uuid) -> ImksResult<Vec<MessageComponent>> {
|
||||
sqlx::query_as::<_, MessageComponent>(
|
||||
r#"
|
||||
SELECT * FROM message_component
|
||||
WHERE message_id = $1
|
||||
ORDER BY row, position
|
||||
"#,
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Update a component's label and/or disabled state (e.g. after interaction).
|
||||
pub async fn update_component(
|
||||
&self,
|
||||
component_id: Uuid,
|
||||
label: Option<&str>,
|
||||
disabled: bool,
|
||||
) -> ImksResult<Option<MessageComponent>> {
|
||||
sqlx::query_as::<_, MessageComponent>(
|
||||
r#"
|
||||
UPDATE message_component
|
||||
SET label = COALESCE($1, label), disabled = $2
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(label)
|
||||
.bind(disabled)
|
||||
.bind(component_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Message write operations — insert, update body, soft delete.
|
||||
//!
|
||||
//! All mutations use parameterized queries and return the affected row(s).
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message::{Message, new_message_id};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
/// Input payload for creating a new message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateMessageInput {
|
||||
/// Target channel UUID.
|
||||
pub channel_id: Uuid,
|
||||
/// Author (user) UUID — extracted from JWT `sub` claim.
|
||||
pub author_id: Uuid,
|
||||
/// Thread this message belongs to (`None` = top-level).
|
||||
pub thread_id: Option<Uuid>,
|
||||
/// Direct reply reference (`None` = not a reply).
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
/// Discriminator: `"text"`, `"system"`, `"event"`, `"article"`.
|
||||
pub message_type: String,
|
||||
/// Plain text or markdown body.
|
||||
pub body: String,
|
||||
/// Extensible metadata (flags, locale, etc.).
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
/// Whether this is a system/bot-generated message.
|
||||
pub system: bool,
|
||||
}
|
||||
|
||||
impl MessageRepo {
|
||||
/// Insert a new message row and return it.
|
||||
///
|
||||
/// The message ID is a fresh UUID v7 (time-ordered).
|
||||
pub async fn create(&self, input: &CreateMessageInput) -> ImksResult<Message> {
|
||||
let id = new_message_id();
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query_as::<_, Message>(
|
||||
r#"
|
||||
INSERT INTO message (
|
||||
id, channel_id, author_id, thread_id, reply_to_message_id,
|
||||
message_type, body, metadata, pinned, system,
|
||||
edited_at, deleted_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, FALSE, $9,
|
||||
NULL, NULL, $10, $10
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(input.channel_id)
|
||||
.bind(input.author_id)
|
||||
.bind(input.thread_id)
|
||||
.bind(input.reply_to_message_id)
|
||||
.bind(&input.message_type)
|
||||
.bind(&input.body)
|
||||
.bind(&input.metadata)
|
||||
.bind(input.system)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Update the body of an existing message. Sets `edited_at` and `updated_at`.
|
||||
///
|
||||
/// Returns the updated row, or an error if the message is not found or deleted.
|
||||
pub async fn update_body(&self, message_id: Uuid, new_body: &str) -> ImksResult<Message> {
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query_as::<_, Message>(
|
||||
r#"
|
||||
UPDATE message
|
||||
SET body = $1, edited_at = $2, updated_at = $2
|
||||
WHERE id = $3 AND deleted_at IS NULL
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(new_body)
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::NotFound(format!("message {message_id}")))?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Soft-delete a message by setting `deleted_at`.
|
||||
///
|
||||
/// Returns `Ok(())` even if the message was already deleted.
|
||||
pub async fn soft_delete(&self, message_id: Uuid) -> ImksResult<()> {
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message
|
||||
SET deleted_at = $1, updated_at = $1
|
||||
WHERE id = $2 AND deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Draft CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! One draft per (channel, user, thread). Upserted on every keystroke debounce,
|
||||
//! deleted on send. Thread_id=NULL uses a dedicated conflict target.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_draft::MessageDraft;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Upsert a draft for the given (channel, user, thread) key.
|
||||
/// Uses NULL-safe conflict handling via COALESCE.
|
||||
pub async fn upsert_draft(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
body: &str,
|
||||
reply_to_message_id: Option<Uuid>,
|
||||
metadata: Option<serde_json::Value>,
|
||||
) -> ImksResult<MessageDraft> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let query = if thread_id.is_some() {
|
||||
r#"
|
||||
INSERT INTO message_draft (
|
||||
id, channel_id, user_id, thread_id, reply_to_message_id,
|
||||
body, metadata, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (channel_id, user_id, thread_id) DO UPDATE SET
|
||||
body = EXCLUDED.body,
|
||||
reply_to_message_id = EXCLUDED.reply_to_message_id,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
"#
|
||||
} else {
|
||||
r#"
|
||||
INSERT INTO message_draft (
|
||||
id, channel_id, user_id, thread_id, reply_to_message_id,
|
||||
body, metadata, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (channel_id, user_id) WHERE thread_id IS NULL DO UPDATE SET
|
||||
body = EXCLUDED.body,
|
||||
reply_to_message_id = EXCLUDED.reply_to_message_id,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
"#
|
||||
};
|
||||
|
||||
sqlx::query_as::<_, MessageDraft>(query)
|
||||
.bind(id)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(thread_id)
|
||||
.bind(reply_to_message_id)
|
||||
.bind(body)
|
||||
.bind(metadata)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get a user's draft for a channel (optionally scoped to a thread).
|
||||
pub async fn get_draft(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
) -> ImksResult<Option<MessageDraft>> {
|
||||
sqlx::query_as::<_, MessageDraft>(
|
||||
r#"
|
||||
SELECT * FROM message_draft
|
||||
WHERE channel_id = $1 AND user_id = $2 AND thread_id IS NOT DISTINCT FROM $3
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(thread_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Delete a draft after the message is sent.
|
||||
pub async fn delete_draft(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
) -> ImksResult<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM message_draft
|
||||
WHERE channel_id = $1 AND user_id = $2 AND thread_id IS NOT DISTINCT FROM $3
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(thread_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Edit history CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! Immutable append-only log. Every message body edit creates one row.
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_edit::{EditSummary, MessageEdit};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Record an edit to a message's body.
|
||||
pub async fn record_edit(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
edited_by: Uuid,
|
||||
old_body: &str,
|
||||
new_body: &str,
|
||||
) -> ImksResult<MessageEdit> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query_as::<_, MessageEdit>(
|
||||
r#"
|
||||
INSERT INTO message_edit (id, message_id, edited_by, old_body, new_body, edited_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(message_id)
|
||||
.bind(edited_by)
|
||||
.bind(old_body)
|
||||
.bind(new_body)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get the full edit history for a message, oldest first.
|
||||
pub async fn get_edit_history(&self, message_id: Uuid) -> ImksResult<Vec<MessageEdit>> {
|
||||
sqlx::query_as::<_, MessageEdit>(
|
||||
"SELECT * FROM message_edit WHERE message_id = $1 ORDER BY edited_at ASC",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get a summary of edits for a message (count + last editor).
|
||||
pub async fn get_edit_summary(&self, message_id: Uuid) -> ImksResult<EditSummary> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COUNT(*)::BIGINT AS edit_count,
|
||||
MAX(edited_at) AS last_edited_at,
|
||||
(ARRAY_AGG(edited_by ORDER BY edited_at DESC))[1] AS last_edited_by
|
||||
FROM message_edit
|
||||
WHERE message_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(EditSummary {
|
||||
edit_count: row.get("edit_count"),
|
||||
last_edited_at: row.get("last_edited_at"),
|
||||
last_edited_by: row.get("last_edited_by"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Embed CRUD operations on `MessageRepo`.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_embed::{EmbedDetail, MessageEmbed, MessageEmbedField};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create an embed with its fields. Returns the embed (fields fetched separately).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_embed(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
embed_type: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
url: Option<&str>,
|
||||
color: Option<i32>,
|
||||
image_url: Option<&str>,
|
||||
author_name: Option<&str>,
|
||||
author_url: Option<&str>,
|
||||
footer_text: Option<&str>,
|
||||
provider_name: Option<&str>,
|
||||
fields: &[(String, String, bool)],
|
||||
) -> ImksResult<MessageEmbed> {
|
||||
let embed_id = Uuid::now_v7();
|
||||
|
||||
let embed = sqlx::query_as::<_, MessageEmbed>(
|
||||
r#"
|
||||
INSERT INTO message_embed (
|
||||
id, message_id, embed_type, title, description, url, color,
|
||||
image_url, author_name, author_url, footer_text, provider_name
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(embed_id)
|
||||
.bind(message_id)
|
||||
.bind(embed_type)
|
||||
.bind(title)
|
||||
.bind(description)
|
||||
.bind(url)
|
||||
.bind(color)
|
||||
.bind(image_url)
|
||||
.bind(author_name)
|
||||
.bind(author_url)
|
||||
.bind(footer_text)
|
||||
.bind(provider_name)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
for (i, (name, value, inline)) in fields.iter().enumerate() {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO message_embed_field (id, embed_id, name, value, inline, position)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(embed_id)
|
||||
.bind(name)
|
||||
.bind(value)
|
||||
.bind(inline)
|
||||
.bind(i as i32)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(embed)
|
||||
}
|
||||
|
||||
/// Get all embeds for a message, including their fields.
|
||||
pub async fn get_embeds(&self, message_id: Uuid) -> ImksResult<Vec<EmbedDetail>> {
|
||||
let embeds: Vec<MessageEmbed> =
|
||||
sqlx::query_as("SELECT * FROM message_embed WHERE message_id = $1 ORDER BY created_at")
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::with_capacity(embeds.len());
|
||||
for embed in embeds {
|
||||
let fields: Vec<MessageEmbedField> = sqlx::query_as(
|
||||
"SELECT * FROM message_embed_field WHERE embed_id = $1 ORDER BY position",
|
||||
)
|
||||
.bind(embed.id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
|
||||
result.push(EmbedDetail { embed, fields });
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delete an embed (fields cascade via FK).
|
||||
pub async fn delete_embed(&self, embed_id: Uuid) -> ImksResult<bool> {
|
||||
let result = sqlx::query("DELETE FROM message_embed WHERE id = $1")
|
||||
.bind(embed_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Message forward CRUD operations on `MessageRepo`.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_forward::MessageForward;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Record a forwarded message's provenance.
|
||||
pub async fn record_forward(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
source_message_id: Uuid,
|
||||
source_channel_id: Uuid,
|
||||
forwarded_by: Uuid,
|
||||
) -> ImksResult<MessageForward> {
|
||||
sqlx::query_as::<_, MessageForward>(
|
||||
r#"
|
||||
INSERT INTO message_forward (id, message_id, source_message_id, source_channel_id, forwarded_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(source_message_id)
|
||||
.bind(source_channel_id)
|
||||
.bind(forwarded_by)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get forwarding info for a message.
|
||||
pub async fn get_forward_info(&self, message_id: Uuid) -> ImksResult<Option<MessageForward>> {
|
||||
sqlx::query_as::<_, MessageForward>("SELECT * FROM message_forward WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Mention CRUD operations on `MessageRepo`.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_mention::MessageMention;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
use super::pagination::{CursorPage, clamp_limit};
|
||||
|
||||
impl MessageRepo {
|
||||
/// Bulk record mentions for a message. Called right after message creation.
|
||||
pub async fn record_mentions(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
mentioned_by: Uuid,
|
||||
mentioned_user_ids: &[Uuid],
|
||||
) -> ImksResult<()> {
|
||||
if mentioned_user_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
for &user_id in mentioned_user_ids {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO message_mention (id, message_id, channel_id, mentioned_user_id, mentioned_by, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(mentioned_by)
|
||||
.bind(now)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List mentions for a specific user, newest first.
|
||||
pub async fn list_mentions_for_user(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ImksResult<CursorPage<MessageMention>> {
|
||||
let effective_limit = clamp_limit(limit);
|
||||
let fetch_limit = effective_limit + 1;
|
||||
|
||||
let rows = match before {
|
||||
Some(cursor) => {
|
||||
sqlx::query_as::<_, MessageMention>(
|
||||
r#"
|
||||
SELECT * FROM message_mention
|
||||
WHERE mentioned_user_id = $1 AND id < $2
|
||||
ORDER BY id DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(cursor)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, MessageMention>(
|
||||
r#"
|
||||
SELECT * FROM message_mention
|
||||
WHERE mentioned_user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CursorPage::from_raw(rows, effective_limit, |m| m.id))
|
||||
}
|
||||
|
||||
/// Mark a mention as read.
|
||||
pub async fn mark_mention_read(&self, mention_id: Uuid) -> ImksResult<()> {
|
||||
sqlx::query("UPDATE message_mention SET read_at = $1 WHERE id = $2")
|
||||
.bind(Utc::now())
|
||||
.bind(mention_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all mentions for a message (e.g. on message delete).
|
||||
pub async fn delete_mentions(&self, message_id: Uuid) -> ImksResult<()> {
|
||||
sqlx::query("DELETE FROM message_mention WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Notification CRUD operations on `MessageRepo`.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_notification::MessageNotification;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
use super::pagination::{CursorPage, clamp_limit};
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create a notification for a user triggered by a message.
|
||||
pub async fn create_notification(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
reason: &str,
|
||||
delivery_channel: Option<&str>,
|
||||
) -> ImksResult<MessageNotification> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query_as::<_, MessageNotification>(
|
||||
r#"
|
||||
INSERT INTO message_notification (
|
||||
id, message_id, channel_id, user_id, reason, status, delivery_channel, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(reason)
|
||||
.bind(delivery_channel)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Mark a notification as read.
|
||||
pub async fn mark_notification_read(&self, notification_id: Uuid) -> ImksResult<()> {
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_notification
|
||||
SET status = 'read', read_at = $1
|
||||
WHERE id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.bind(notification_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark all of a user's notifications as read.
|
||||
pub async fn mark_all_notifications_read(&self, user_id: Uuid) -> ImksResult<u64> {
|
||||
let now = Utc::now();
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE message_notification
|
||||
SET status = 'read', read_at = $1
|
||||
WHERE user_id = $2 AND status != 'read'
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// List notifications for a user, newest first.
|
||||
pub async fn list_notifications(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ImksResult<CursorPage<MessageNotification>> {
|
||||
let effective_limit = clamp_limit(limit);
|
||||
let fetch_limit = effective_limit + 1;
|
||||
|
||||
let rows = match before {
|
||||
Some(cursor) => {
|
||||
sqlx::query_as::<_, MessageNotification>(
|
||||
r#"
|
||||
SELECT * FROM message_notification
|
||||
WHERE user_id = $1 AND id < $2
|
||||
ORDER BY id DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(cursor)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, MessageNotification>(
|
||||
r#"
|
||||
SELECT * FROM message_notification
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CursorPage::from_raw(rows, effective_limit, |n| n.id))
|
||||
}
|
||||
|
||||
/// Get unread notification count for a user.
|
||||
pub async fn get_unread_notification_count(&self, user_id: Uuid) -> ImksResult<i64> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)::BIGINT FROM message_notification
|
||||
WHERE user_id = $1 AND status = 'pending'
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Pin CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! One row per pinned message. Channels can have multiple pinned messages.
|
||||
//! `position` auto-calculated as `MAX(position) + 1` within the channel.
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_pin::{MessagePin, PinDetail};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Pin a message in a channel. Computes the next position automatically.
|
||||
pub async fn pin_message(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
pinned_by: Uuid,
|
||||
) -> ImksResult<MessagePin> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
let mut tx = self.pool().begin().await?;
|
||||
|
||||
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
||||
.bind(channel_id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let max_pos: Option<i32> = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM message_pin WHERE channel_id = $1",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let position = max_pos.unwrap_or(-1) + 1;
|
||||
|
||||
let pin = sqlx::query_as::<_, MessagePin>(
|
||||
r#"
|
||||
INSERT INTO message_pin (id, channel_id, message_id, pinned_by, position, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (channel_id, message_id) DO NOTHING
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(channel_id)
|
||||
.bind(message_id)
|
||||
.bind(pinned_by)
|
||||
.bind(position)
|
||||
.bind(now)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::InvalidInput("Message already pinned".into()))?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(pin)
|
||||
}
|
||||
|
||||
/// Unpin a message from a channel.
|
||||
pub async fn unpin_message(&self, channel_id: Uuid, message_id: Uuid) -> ImksResult<bool> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM message_pin WHERE channel_id = $1 AND message_id = $2")
|
||||
.bind(channel_id)
|
||||
.bind(message_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// List all pinned messages in a channel, newest first, joined with message content.
|
||||
pub async fn list_pins(&self, channel_id: Uuid) -> ImksResult<Vec<PinDetail>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT p.*, m.body AS message_body, m.author_id AS message_author_id,
|
||||
m.created_at AS message_created_at
|
||||
FROM message_pin p
|
||||
JOIN message m ON m.id = p.message_id
|
||||
WHERE p.channel_id = $1 AND m.deleted_at IS NULL
|
||||
ORDER BY p.position ASC
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| PinDetail {
|
||||
pin: MessagePin {
|
||||
id: row.get("id"),
|
||||
channel_id: row.get("channel_id"),
|
||||
message_id: row.get("message_id"),
|
||||
pinned_by: row.get("pinned_by"),
|
||||
position: row.get("position"),
|
||||
created_at: row.get("created_at"),
|
||||
},
|
||||
message_body: row.get("message_body"),
|
||||
message_author_id: row.get("message_author_id"),
|
||||
message_created_at: row.get("message_created_at"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Poll CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! Handles poll creation (with options), voting (with denormalized counts),
|
||||
//! and result retrieval.
|
||||
|
||||
use chrono::Utc;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_poll::{MessagePoll, MessagePollOption, MessagePollVote, PollResult};
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
/// Canonical poll target resolved from the database.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PollTarget {
|
||||
pub poll_id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
impl MessageRepo {
|
||||
/// Resolve and validate a poll option's canonical message/channel target.
|
||||
pub async fn get_poll_target(&self, poll_id: Uuid, option_id: Uuid) -> ImksResult<PollTarget> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT p.id AS poll_id, p.message_id, m.channel_id, o.id AS option_id
|
||||
FROM message_poll p
|
||||
JOIN message m ON m.id = p.message_id
|
||||
JOIN message_poll_option o ON o.poll_id = p.id AND o.id = $2
|
||||
WHERE p.id = $1 AND m.deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::NotFound(format!("poll {poll_id} option {option_id}")))?;
|
||||
|
||||
Ok(PollTarget {
|
||||
poll_id: row.get("poll_id"),
|
||||
message_id: row.get("message_id"),
|
||||
channel_id: row.get("channel_id"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a poll with its options. Returns the poll (options fetched separately).
|
||||
pub async fn create_poll(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
question: &str,
|
||||
allow_multiselect: bool,
|
||||
max_selections: Option<i32>,
|
||||
expires_at: Option<chrono::DateTime<Utc>>,
|
||||
options: &[(String, Option<String>)],
|
||||
) -> ImksResult<MessagePoll> {
|
||||
let poll_id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let poll = sqlx::query_as::<_, MessagePoll>(
|
||||
r#"
|
||||
INSERT INTO message_poll (
|
||||
id, message_id, question, allow_multiselect,
|
||||
max_selections, expires_at, total_votes, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $7)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(message_id)
|
||||
.bind(question)
|
||||
.bind(allow_multiselect)
|
||||
.bind(max_selections)
|
||||
.bind(expires_at)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
for (i, (text, emoji)) in options.iter().enumerate() {
|
||||
let opt_id = Uuid::now_v7();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO message_poll_option (id, poll_id, text, emoji, vote_count, position)
|
||||
VALUES ($1, $2, $3, $4, 0, $5)
|
||||
"#,
|
||||
)
|
||||
.bind(opt_id)
|
||||
.bind(poll_id)
|
||||
.bind(text)
|
||||
.bind(emoji.as_deref())
|
||||
.bind(i as i32)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(poll)
|
||||
}
|
||||
|
||||
/// Cast a validated vote and return the canonical message/channel target.
|
||||
pub async fn cast_vote_checked(
|
||||
&self,
|
||||
poll_id: Uuid,
|
||||
option_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<PollTarget> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT p.id AS poll_id, p.message_id, p.allow_multiselect, p.max_selections,
|
||||
p.expires_at, m.channel_id, o.id AS option_id
|
||||
FROM message_poll p
|
||||
JOIN message m ON m.id = p.message_id
|
||||
JOIN message_poll_option o ON o.poll_id = p.id AND o.id = $2
|
||||
WHERE p.id = $1 AND m.deleted_at IS NULL
|
||||
FOR UPDATE OF p
|
||||
"#,
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::NotFound(format!("poll {poll_id} option {option_id}")))?;
|
||||
|
||||
let expires_at: Option<chrono::DateTime<Utc>> = row.get("expires_at");
|
||||
if expires_at.is_some_and(|exp| now >= exp) {
|
||||
return Err(crate::ImksError::InvalidInput("Poll has expired".into()));
|
||||
}
|
||||
|
||||
let allow_multiselect: bool = row.get("allow_multiselect");
|
||||
let max_selections: Option<i32> = row.get("max_selections");
|
||||
let current_votes: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT option_id FROM message_poll_vote WHERE poll_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if current_votes.contains(&option_id) {
|
||||
return Err(crate::ImksError::InvalidInput(
|
||||
"Already voted for this option".into(),
|
||||
));
|
||||
}
|
||||
if !allow_multiselect && !current_votes.is_empty() {
|
||||
return Err(crate::ImksError::InvalidInput(
|
||||
"Poll allows only one selection".into(),
|
||||
));
|
||||
}
|
||||
if let Some(max) = max_selections
|
||||
&& current_votes.len() >= max.max(1) as usize
|
||||
{
|
||||
return Err(crate::ImksError::InvalidInput(
|
||||
"Poll selection limit exceeded".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let vote_id = Uuid::now_v7();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO message_poll_vote (id, poll_id, option_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
"#,
|
||||
)
|
||||
.bind(vote_id)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE message_poll_option SET vote_count = vote_count + 1 WHERE id = $1")
|
||||
.bind(option_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll SET total_votes = total_votes + 1, updated_at = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(poll_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(PollTarget {
|
||||
poll_id,
|
||||
message_id: row.get("message_id"),
|
||||
channel_id: row.get("channel_id"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove a validated vote and return the canonical message/channel target.
|
||||
pub async fn remove_vote_checked(
|
||||
&self,
|
||||
poll_id: Uuid,
|
||||
option_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<Option<PollTarget>> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT p.id AS poll_id, p.message_id, m.channel_id, o.id AS option_id
|
||||
FROM message_poll p
|
||||
JOIN message m ON m.id = p.message_id
|
||||
JOIN message_poll_option o ON o.poll_id = p.id AND o.id = $2
|
||||
WHERE p.id = $1 AND m.deleted_at IS NULL
|
||||
FOR UPDATE OF p
|
||||
"#,
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::NotFound(format!("poll {poll_id} option {option_id}")))?;
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM message_poll_vote
|
||||
WHERE poll_id = $1 AND option_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
tx.commit().await?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll_option SET vote_count = GREATEST(vote_count - 1, 0) WHERE id = $1",
|
||||
)
|
||||
.bind(option_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll SET total_votes = GREATEST(total_votes - 1, 0), updated_at = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(poll_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Some(PollTarget {
|
||||
poll_id,
|
||||
message_id: row.get("message_id"),
|
||||
channel_id: row.get("channel_id"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Cast a vote. Increments denormalized counts atomically.
|
||||
pub async fn vote(
|
||||
&self,
|
||||
poll_id: Uuid,
|
||||
option_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<MessagePollVote> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let vote = sqlx::query_as::<_, MessagePollVote>(
|
||||
r#"
|
||||
INSERT INTO message_poll_vote (id, poll_id, option_id, user_id, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (poll_id, user_id, option_id) DO NOTHING
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.fetch_optional(self.pool())
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::InvalidInput("Already voted for this option".into()))?;
|
||||
|
||||
sqlx::query("UPDATE message_poll_option SET vote_count = vote_count + 1 WHERE id = $1")
|
||||
.bind(option_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll SET total_votes = total_votes + 1, updated_at = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(poll_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(vote)
|
||||
}
|
||||
|
||||
/// Remove a vote. Decrements denormalized counts.
|
||||
pub async fn remove_vote(
|
||||
&self,
|
||||
poll_id: Uuid,
|
||||
option_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM message_poll_vote
|
||||
WHERE poll_id = $1 AND option_id = $2 AND user_id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll_option SET vote_count = GREATEST(vote_count - 1, 0) WHERE id = $1",
|
||||
)
|
||||
.bind(option_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll SET total_votes = GREATEST(total_votes - 1, 0), updated_at = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(Utc::now())
|
||||
.bind(poll_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get full poll results including options, vote counts, and the given user's votes.
|
||||
pub async fn get_poll_result(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<Option<PollResult>> {
|
||||
let poll =
|
||||
sqlx::query_as::<_, MessagePoll>("SELECT * FROM message_poll WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
let Some(poll) = poll else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let options: Vec<MessagePollOption> = sqlx::query_as(
|
||||
"SELECT * FROM message_poll_option WHERE poll_id = $1 ORDER BY position",
|
||||
)
|
||||
.bind(poll.id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
|
||||
let my_votes: Vec<Uuid> = sqlx::query_scalar(
|
||||
"SELECT option_id FROM message_poll_vote WHERE poll_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(poll.id)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(Some(PollResult::from_poll(poll, options, my_votes)))
|
||||
}
|
||||
|
||||
/// Close a poll by setting its expiration to now.
|
||||
pub async fn close_poll(&self, message_id: Uuid) -> ImksResult<()> {
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_poll
|
||||
SET expires_at = $1, updated_at = $1
|
||||
WHERE message_id = $2 AND (expires_at IS NULL OR expires_at > $1)
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Message read operations — get single, list by channel, list by thread.
|
||||
//!
|
||||
//! All list queries use UUID v7 cursor-based pagination (no OFFSET).
|
||||
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message::Message;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
use super::pagination::{CursorPage, clamp_limit};
|
||||
|
||||
impl MessageRepo {
|
||||
/// Fetch a single message by ID.
|
||||
///
|
||||
/// Returns `None` if the message doesn't exist or has been soft-deleted.
|
||||
pub async fn get(&self, message_id: Uuid) -> ImksResult<Option<Message>> {
|
||||
let row = sqlx::query_as::<_, Message>(
|
||||
"SELECT * FROM message WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// List messages in a channel with cursor-based pagination.
|
||||
///
|
||||
/// Returns messages in reverse chronological order (newest first).
|
||||
/// Pass `before` as the last message ID from the previous page to
|
||||
/// fetch the next page.
|
||||
pub async fn list_by_channel(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ImksResult<CursorPage<Message>> {
|
||||
let effective_limit = clamp_limit(limit);
|
||||
// Fetch one extra row to determine `has_more`.
|
||||
let fetch_limit = effective_limit + 1;
|
||||
|
||||
let rows = match before {
|
||||
Some(cursor) => {
|
||||
sqlx::query_as::<_, Message>(
|
||||
r#"
|
||||
SELECT * FROM message
|
||||
WHERE channel_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND id < $2
|
||||
ORDER BY id DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(cursor)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, Message>(
|
||||
r#"
|
||||
SELECT * FROM message
|
||||
WHERE channel_id = $1
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CursorPage::from_raw(rows, effective_limit, |m| m.id))
|
||||
}
|
||||
|
||||
/// List messages in a thread with cursor-based pagination.
|
||||
pub async fn list_by_thread(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
before: Option<Uuid>,
|
||||
limit: Option<i64>,
|
||||
) -> ImksResult<CursorPage<Message>> {
|
||||
let effective_limit = clamp_limit(limit);
|
||||
let fetch_limit = effective_limit + 1;
|
||||
|
||||
let rows = match before {
|
||||
Some(cursor) => {
|
||||
sqlx::query_as::<_, Message>(
|
||||
r#"
|
||||
SELECT * FROM message
|
||||
WHERE thread_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND id < $2
|
||||
ORDER BY id DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(cursor)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as::<_, Message>(
|
||||
r#"
|
||||
SELECT * FROM message
|
||||
WHERE thread_id = $1
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(fetch_limit)
|
||||
.fetch_all(self.pool())
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(CursorPage::from_raw(rows, effective_limit, |m| m.id))
|
||||
}
|
||||
|
||||
/// Get message reaction counts grouped by emoji content.
|
||||
///
|
||||
/// Returns `(content, count)` pairs for the given message.
|
||||
pub async fn get_reaction_counts(&self, message_id: Uuid) -> ImksResult<Vec<(String, i64)>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT content, COUNT(*)::BIGINT AS cnt
|
||||
FROM message_reaction
|
||||
WHERE message_id = $1
|
||||
GROUP BY content
|
||||
"#,
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| (r.get("content"), r.get("cnt")))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Count attachments and embeds for a message.
|
||||
pub async fn get_content_counts(&self, message_id: Uuid) -> ImksResult<(i64, i64)> {
|
||||
let att_row = sqlx::query(
|
||||
"SELECT COUNT(*)::BIGINT AS cnt FROM message_attachment WHERE message_id = $1",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
let emb_row =
|
||||
sqlx::query("SELECT COUNT(*)::BIGINT AS cnt FROM message_embed WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok((att_row.get("cnt"), emb_row.get("cnt")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Reaction CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! Each (message, user, content) tuple is unique via ON CONFLICT.
|
||||
//! Toggle semantics: same request adds/removes the reaction.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_reaction::MessageReaction;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Add or toggle a reaction. Returns the reaction if added, None if already exists.
|
||||
pub async fn add_reaction(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
content: &str,
|
||||
) -> ImksResult<Option<MessageReaction>> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let row = sqlx::query_as::<_, MessageReaction>(
|
||||
r#"
|
||||
INSERT INTO message_reaction (id, message_id, channel_id, user_id, content, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (message_id, user_id, content) DO NOTHING
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(content)
|
||||
.bind(now)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Remove a user's reaction from a message.
|
||||
pub async fn remove_reaction(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
user_id: Uuid,
|
||||
content: &str,
|
||||
) -> ImksResult<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM message_reaction
|
||||
WHERE message_id = $1 AND user_id = $2 AND content = $3
|
||||
"#,
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(user_id)
|
||||
.bind(content)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Get all reactions on a message.
|
||||
pub async fn get_reactions(&self, message_id: Uuid) -> ImksResult<Vec<MessageReaction>> {
|
||||
sqlx::query_as::<_, MessageReaction>(
|
||||
"SELECT * FROM message_reaction WHERE message_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get a specific user's reactions on a message.
|
||||
pub async fn get_user_reactions(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<Vec<MessageReaction>> {
|
||||
sqlx::query_as::<_, MessageReaction>(
|
||||
"SELECT * FROM message_reaction WHERE message_id = $1 AND user_id = $2 ORDER BY created_at",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Read state CRUD operations on `MessageRepo`.
|
||||
//!
|
||||
//! One row per (channel, user). Upserted on each read; ON CONFLICT DO UPDATE
|
||||
//! advances the cursor and recalculates unread counts.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_read_state::MessageReadState;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Mark a channel as read up to a given message for a user.
|
||||
/// Recalculates unread_count and unread_mentions from the database.
|
||||
pub async fn mark_read(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
last_read_message_id: Uuid,
|
||||
) -> ImksResult<MessageReadState> {
|
||||
let now = Utc::now();
|
||||
|
||||
let unread_count: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM message
|
||||
WHERE channel_id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND id > $2
|
||||
AND author_id != $3
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(last_read_message_id)
|
||||
.bind(user_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
let unread_mentions: i64 = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
FROM message_mention mm
|
||||
WHERE mm.channel_id = $1
|
||||
AND mm.mentioned_user_id = $2
|
||||
AND mm.message_id > $3
|
||||
AND mm.read_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(last_read_message_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
let id = Uuid::now_v7();
|
||||
|
||||
sqlx::query_as::<_, MessageReadState>(
|
||||
r#"
|
||||
INSERT INTO message_read_state (
|
||||
id, channel_id, user_id, last_read_message_id, last_read_at,
|
||||
unread_count, unread_mentions, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET
|
||||
last_read_message_id = EXCLUDED.last_read_message_id,
|
||||
last_read_at = EXCLUDED.last_read_at,
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
unread_mentions = EXCLUDED.unread_mentions,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.bind(last_read_message_id)
|
||||
.bind(now)
|
||||
.bind(unread_count)
|
||||
.bind(unread_mentions)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get a user's read state for a channel.
|
||||
pub async fn get_read_state(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<Option<MessageReadState>> {
|
||||
sqlx::query_as::<_, MessageReadState>(
|
||||
"SELECT * FROM message_read_state WHERE channel_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get read state summaries for all channels a user participates in.
|
||||
pub async fn get_user_read_states(&self, user_id: Uuid) -> ImksResult<Vec<MessageReadState>> {
|
||||
sqlx::query_as::<_, MessageReadState>("SELECT * FROM message_read_state WHERE user_id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Message repository — struct definition and pool accessor.
|
||||
//!
|
||||
//! CRUD operations are split across `message_create.rs` (writes)
|
||||
//! and `message_query.rs` (reads) as separate `impl` blocks.
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Repository for message CRUD operations.
|
||||
///
|
||||
/// All queries use parameterized statements via sqlx.
|
||||
/// IDs are UUID v7 (time-ordered) for efficient cursor pagination.
|
||||
#[derive(Clone)]
|
||||
pub struct MessageRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create a new repository backed by the given connection pool.
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Access the inner `PgPool` for advanced queries.
|
||||
pub fn pool(&self) -> &PgPool {
|
||||
&self.pool
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Scheduled message CRUD operations on `MessageRepo`.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_scheduled::MessageScheduled;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Schedule a message to be sent later.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn schedule_message(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
author_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
reply_to_message_id: Option<Uuid>,
|
||||
body: &str,
|
||||
metadata: Option<serde_json::Value>,
|
||||
scheduled_at: chrono::DateTime<Utc>,
|
||||
) -> ImksResult<MessageScheduled> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query_as::<_, MessageScheduled>(
|
||||
r#"
|
||||
INSERT INTO message_scheduled (
|
||||
id, channel_id, author_id, thread_id, reply_to_message_id,
|
||||
body, metadata, scheduled_at, status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $9)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(channel_id)
|
||||
.bind(author_id)
|
||||
.bind(thread_id)
|
||||
.bind(reply_to_message_id)
|
||||
.bind(body)
|
||||
.bind(metadata)
|
||||
.bind(scheduled_at)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Cancel a scheduled message (only if still pending).
|
||||
pub async fn cancel_scheduled(&self, scheduled_id: Uuid) -> ImksResult<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE message_scheduled
|
||||
SET status = 'cancelled', updated_at = $1
|
||||
WHERE id = $2 AND status = 'pending'
|
||||
"#,
|
||||
)
|
||||
.bind(Utc::now())
|
||||
.bind(scheduled_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// List a user's scheduled messages.
|
||||
pub async fn list_scheduled(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
author_id: Uuid,
|
||||
) -> ImksResult<Vec<MessageScheduled>> {
|
||||
sqlx::query_as::<_, MessageScheduled>(
|
||||
r#"
|
||||
SELECT * FROM message_scheduled
|
||||
WHERE channel_id = $1 AND author_id = $2 AND status = 'pending'
|
||||
ORDER BY scheduled_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(author_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Atomically claim due scheduled messages for background dispatch.
|
||||
pub async fn claim_due_scheduled(&self) -> ImksResult<Vec<MessageScheduled>> {
|
||||
let mut tx = self.pool().begin().await?;
|
||||
let now = Utc::now();
|
||||
|
||||
let rows = sqlx::query_as::<_, MessageScheduled>(
|
||||
r#"
|
||||
UPDATE message_scheduled
|
||||
SET status = 'processing', updated_at = $1
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM message_scheduled
|
||||
WHERE status = 'pending' AND scheduled_at <= $1
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 100
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(now)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Get all pending scheduled messages whose time has come (for background dispatch).
|
||||
pub async fn get_due_scheduled(&self) -> ImksResult<Vec<MessageScheduled>> {
|
||||
self.claim_due_scheduled().await
|
||||
}
|
||||
|
||||
/// Mark a scheduled message as sent.
|
||||
pub async fn mark_scheduled_sent(
|
||||
&self,
|
||||
scheduled_id: Uuid,
|
||||
sent_message_id: Uuid,
|
||||
) -> ImksResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_scheduled
|
||||
SET status = 'sent', sent_message_id = $1, updated_at = $2
|
||||
WHERE id = $3 AND status = 'processing'
|
||||
"#,
|
||||
)
|
||||
.bind(sent_message_id)
|
||||
.bind(Utc::now())
|
||||
.bind(scheduled_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a scheduled message as failed.
|
||||
pub async fn mark_scheduled_failed(&self, scheduled_id: Uuid, error: &str) -> ImksResult<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_scheduled
|
||||
SET status = 'failed', error = $1, updated_at = $2
|
||||
WHERE id = $3 AND status = 'processing'
|
||||
"#,
|
||||
)
|
||||
.bind(error)
|
||||
.bind(Utc::now())
|
||||
.bind(scheduled_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Sticker CRUD operations on `MessageRepo`.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_sticker::MessageSticker;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Record a sticker used in a message.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn record_sticker(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
sticker_id: Uuid,
|
||||
name: &str,
|
||||
image_url: &str,
|
||||
format_type: &str,
|
||||
pack_name: Option<&str>,
|
||||
tags: Option<&str>,
|
||||
) -> ImksResult<MessageSticker> {
|
||||
sqlx::query_as::<_, MessageSticker>(
|
||||
r#"
|
||||
INSERT INTO message_sticker (id, message_id, sticker_id, name, image_url, format_type, pack_name, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(sticker_id)
|
||||
.bind(name)
|
||||
.bind(image_url)
|
||||
.bind(format_type)
|
||||
.bind(pack_name)
|
||||
.bind(tags)
|
||||
.fetch_one(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get all stickers on a message.
|
||||
pub async fn get_stickers(&self, message_id: Uuid) -> ImksResult<Vec<MessageSticker>> {
|
||||
sqlx::query_as::<_, MessageSticker>(
|
||||
"SELECT * FROM message_sticker WHERE message_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(message_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Thread CRUD operations on `MessageRepo`.
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksResult;
|
||||
use crate::models::message_thread::MessageThread;
|
||||
use crate::models::message_thread_participant::MessageThreadParticipant;
|
||||
|
||||
use super::message_repo::MessageRepo;
|
||||
|
||||
impl MessageRepo {
|
||||
/// Create a new thread anchored on a root message.
|
||||
pub async fn create_thread(
|
||||
&self,
|
||||
root_message_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
created_by: Uuid,
|
||||
) -> ImksResult<MessageThread> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query_as::<_, MessageThread>(
|
||||
r#"
|
||||
INSERT INTO message_thread (
|
||||
id, channel_id, root_message_id, created_by,
|
||||
replies_count, participants_count, resolved,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, 0, 0, FALSE, $5, $5)
|
||||
ON CONFLICT (root_message_id) DO NOTHING
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(channel_id)
|
||||
.bind(root_message_id)
|
||||
.bind(created_by)
|
||||
.bind(now)
|
||||
.fetch_optional(self.pool())
|
||||
.await?
|
||||
.ok_or_else(|| crate::ImksError::InvalidInput("Thread already exists".into()))
|
||||
}
|
||||
|
||||
/// Get a thread by its ID.
|
||||
pub async fn get_thread(&self, thread_id: Uuid) -> ImksResult<Option<MessageThread>> {
|
||||
sqlx::query_as::<_, MessageThread>("SELECT * FROM message_thread WHERE id = $1")
|
||||
.bind(thread_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Get a thread by its root message ID.
|
||||
pub async fn get_thread_by_root(
|
||||
&self,
|
||||
root_message_id: Uuid,
|
||||
) -> ImksResult<Option<MessageThread>> {
|
||||
sqlx::query_as::<_, MessageThread>(
|
||||
"SELECT * FROM message_thread WHERE root_message_id = $1",
|
||||
)
|
||||
.bind(root_message_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// List threads in a channel.
|
||||
pub async fn list_threads(&self, channel_id: Uuid) -> ImksResult<Vec<MessageThread>> {
|
||||
sqlx::query_as::<_, MessageThread>(
|
||||
r#"
|
||||
SELECT * FROM message_thread
|
||||
WHERE channel_id = $1
|
||||
ORDER BY last_reply_at DESC NULLS LAST
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Increment thread reply counter and update last reply info.
|
||||
pub async fn bump_thread(&self, thread_id: Uuid, message_id: Uuid) -> ImksResult<()> {
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_thread
|
||||
SET replies_count = replies_count + 1,
|
||||
last_reply_message_id = $1,
|
||||
last_reply_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(now)
|
||||
.bind(thread_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve or unresolve a thread.
|
||||
pub async fn resolve_thread(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
resolved_by: Uuid,
|
||||
resolved: bool,
|
||||
) -> ImksResult<()> {
|
||||
if resolved {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_thread
|
||||
SET resolved = TRUE, resolved_by = $1, resolved_at = $2, updated_at = $2
|
||||
WHERE id = $3
|
||||
"#,
|
||||
)
|
||||
.bind(resolved_by)
|
||||
.bind(Utc::now())
|
||||
.bind(thread_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE message_thread
|
||||
SET resolved = FALSE, resolved_by = NULL, resolved_at = NULL, updated_at = $1
|
||||
WHERE id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(Utc::now())
|
||||
.bind(thread_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageRepo {
|
||||
/// Add a participant to a thread (or update their join reason).
|
||||
pub async fn add_thread_participant(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
user_id: Uuid,
|
||||
joined_reason: &str,
|
||||
) -> ImksResult<MessageThreadParticipant> {
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
let participant = sqlx::query_as::<_, MessageThreadParticipant>(
|
||||
r#"
|
||||
INSERT INTO message_thread_participant (id, thread_id, user_id, joined_reason, joined_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (thread_id, user_id) DO UPDATE SET joined_reason = EXCLUDED.joined_reason
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(thread_id)
|
||||
.bind(user_id)
|
||||
.bind(joined_reason)
|
||||
.bind(now)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_thread SET participants_count = (SELECT COUNT(*) FROM message_thread_participant WHERE thread_id = $1) WHERE id = $1",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(participant)
|
||||
}
|
||||
|
||||
/// Remove a participant from a thread.
|
||||
pub async fn remove_thread_participant(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> ImksResult<bool> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM message_thread_participant WHERE thread_id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(user_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE message_thread SET participants_count = GREATEST(participants_count - 1, 0) WHERE id = $1",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// List all participants in a thread.
|
||||
pub async fn list_thread_participants(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
) -> ImksResult<Vec<MessageThreadParticipant>> {
|
||||
sqlx::query_as::<_, MessageThreadParticipant>(
|
||||
"SELECT * FROM message_thread_participant WHERE thread_id = $1 ORDER BY joined_at",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.fetch_all(self.pool())
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
pub mod message_article;
|
||||
pub mod message_attachment;
|
||||
pub mod message_bookmark;
|
||||
pub mod message_component;
|
||||
pub mod message_create;
|
||||
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_query;
|
||||
pub mod message_reaction;
|
||||
pub mod message_read_state;
|
||||
pub mod message_repo;
|
||||
pub mod message_scheduled;
|
||||
pub mod message_sticker;
|
||||
pub mod message_thread;
|
||||
pub mod pagination;
|
||||
|
||||
pub use message_create::CreateMessageInput;
|
||||
pub use message_repo::MessageRepo;
|
||||
pub use pagination::CursorPage;
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Cursor-based pagination helpers for repository queries.
|
||||
//!
|
||||
//! UUID v7 IDs are time-ordered, so `WHERE id < $cursor ORDER BY id DESC`
|
||||
//! naturally yields reverse-chronological pages without OFFSET.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Default number of items per page.
|
||||
pub const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||
/// Hard upper bound on page size to prevent abuse.
|
||||
pub const MAX_PAGE_SIZE: i64 = 100;
|
||||
|
||||
/// Generic cursor-based page response.
|
||||
///
|
||||
/// Returned by list operations in the repo layer. The `next_cursor`
|
||||
/// is the last item's UUID — pass it as `before` in the next request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CursorPage<T> {
|
||||
/// Items in this page (ordered by `id DESC`).
|
||||
pub items: Vec<T>,
|
||||
/// Opaque cursor for the next page. `None` when no more results exist.
|
||||
pub next_cursor: Option<Uuid>,
|
||||
/// Whether there are more results beyond this page.
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
impl<T> CursorPage<T> {
|
||||
/// Build a page from a raw result set that may contain one extra row.
|
||||
///
|
||||
/// If `raw_items.len() > limit`, the extra row is dropped and
|
||||
/// `has_more` is set to `true`.
|
||||
pub fn from_raw(mut raw_items: Vec<T>, limit: i64, get_id: impl Fn(&T) -> Uuid) -> Self {
|
||||
let has_more = raw_items.len() > limit as usize;
|
||||
if has_more {
|
||||
raw_items.truncate(limit as usize);
|
||||
}
|
||||
let next_cursor = if has_more {
|
||||
raw_items.last().map(get_id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
items: raw_items,
|
||||
next_cursor,
|
||||
has_more,
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty page (no results).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
has_more: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a caller-requested limit to `[1, MAX_PAGE_SIZE]`, defaulting to `DEFAULT_PAGE_SIZE`.
|
||||
pub fn clamp_limit(limit: Option<i64>) -> i64 {
|
||||
match limit {
|
||||
Some(n) if n < 1 => DEFAULT_PAGE_SIZE,
|
||||
Some(n) if n > MAX_PAGE_SIZE => MAX_PAGE_SIZE,
|
||||
Some(n) => n,
|
||||
None => DEFAULT_PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clamp_limit_none() {
|
||||
assert_eq!(clamp_limit(None), DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp_limit_zero() {
|
||||
assert_eq!(clamp_limit(Some(0)), DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp_limit_negative() {
|
||||
assert_eq!(clamp_limit(Some(-5)), DEFAULT_PAGE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp_limit_over_max() {
|
||||
assert_eq!(clamp_limit(Some(200)), MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp_limit_valid() {
|
||||
assert_eq!(clamp_limit(Some(25)), 25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_page_empty() {
|
||||
let page: CursorPage<String> = CursorPage::empty();
|
||||
assert!(page.items.is_empty());
|
||||
assert!(!page.has_more);
|
||||
assert!(page.next_cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_page_from_raw_no_overflow() {
|
||||
let items = vec!["a".to_string(), "b".to_string()];
|
||||
let page = CursorPage::from_raw(items, 5, |_| Uuid::nil());
|
||||
assert_eq!(page.items.len(), 2);
|
||||
assert!(!page.has_more);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user