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:
zhenyi
2026-06-11 12:11:05 +08:00
parent 06e8ee96a5
commit 821537186e
111 changed files with 10458 additions and 385 deletions
+263
View File
@@ -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(())
}
}
+83
View File
@@ -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)
}
}
+112
View File
@@ -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))
}
}
+88
View File
@@ -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)
}
}
+116
View File
@@ -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(())
}
}
+113
View File
@@ -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)
}
}
+77
View File
@@ -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"),
})
}
}
+106
View File
@@ -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)
}
}
+44
View File
@@ -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)
}
}
+111
View File
@@ -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(())
}
}
+140
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
}
+396
View File
@@ -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(())
}
}
+169
View File
@@ -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")))
}
}
+94
View File
@@ -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)
}
}
+110
View File
@@ -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)
}
}
+27
View File
@@ -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
}
}
+159
View File
@@ -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(())
}
}
+53
View File
@@ -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)
}
}
+218
View File
@@ -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
View File
@@ -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;
+114
View File
@@ -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);
}
}