From 0d8c86af1652df1f922558ed2b6391453fb982e0 Mon Sep 17 00:00:00 2001 From: Nell Date: Sat, 8 Aug 2026 14:17:24 +0200 Subject: [PATCH] init --- frontend/src/pages/server/channel/index.vue | 168 ++++++++-- frontend/src/stores/message.ts | 298 +++++++++++++++--- migration/src/lib.rs | 28 +- ..._000002_add_message_channel_id_id_index.rs | 31 ++ scripts/generate_messages.py | 215 +++++++++++++ src/domain/dto/message.rs | 24 +- src/models/mod.rs | 40 +-- src/models/prelude.rs | 32 +- src/repositories/message.rs | 94 +++++- src/repositories/types.rs | 1 + src/routes/message/handlers.rs | 48 ++- src/routes/message/mapper.rs | 7 +- 12 files changed, 835 insertions(+), 151 deletions(-) create mode 100644 migration/src/m20260808_000002_add_message_channel_id_id_index.rs create mode 100644 scripts/generate_messages.py diff --git a/frontend/src/pages/server/channel/index.vue b/frontend/src/pages/server/channel/index.vue index 05c5d77..f4644f7 100644 --- a/frontend/src/pages/server/channel/index.vue +++ b/frontend/src/pages/server/channel/index.vue @@ -1,7 +1,6 @@ \ No newline at end of file + diff --git a/frontend/src/stores/message.ts b/frontend/src/stores/message.ts index eba81f9..33ab082 100644 --- a/frontend/src/stores/message.ts +++ b/frontend/src/stores/message.ts @@ -2,7 +2,12 @@ import {defineStore} from "pinia"; import {useApi} from "@/composables/useApi.ts"; import {onGatewayEvent} from "@/plugins/events.ts"; -interface Message { +// Change this value to adjust the maximum number of messages kept in the DOM. +// Directional loads automatically use half of this window. +export const MESSAGE_WINDOW_SIZE = 50; +export const MESSAGE_SHIFT_SIZE = Math.max(1, Math.floor(MESSAGE_WINDOW_SIZE / 2)); + +export interface Message { id: string; channel_id: string; user_id: string; @@ -12,78 +17,289 @@ interface Message { reply_to_id: string | null; } +interface MessagePage { + messages: Message[]; + oldest_id: string | null; + newest_id: string | null; + has_more_before: boolean; + has_more_after: boolean; +} + +interface WindowChange { + addedIds: string[]; + removedIds: string[]; +} + +function compareMessages(left: Message, right: Message): number { + if (left.id < right.id) return -1; + if (left.id > right.id) return 1; + return 0; +} + +function mergeMessages(messages: Message[]): Message[] { + const byId = new Map(); + for (const message of messages) { + byId.set(message.id, message); + } + return Array.from(byId.values()).sort(compareMessages); +} + +async function requestPage( + channelId: string, + params: { limit: number; before_id?: string; after_id?: string }, +): Promise { + const query = new URLSearchParams({ + channel_id: channelId, + limit: String(params.limit), + }); + + if (params.before_id) query.set("before_id", params.before_id); + if (params.after_id) query.set("after_id", params.after_id); + + const response = await useApi().get(`/messages?${query.toString()}`); + if (!response.ok) { + throw new Error(`Message loading failed (${response.status})`); + } + + return response.json() as Promise; +} + export const useMessageStore = defineStore("message", { state: () => ({ messages: [] as Message[], + activeChannelId: null as string | null, + oldestId: null as string | null, + newestId: null as string | null, + hasMoreBefore: false, + hasMoreAfter: false, loading: false, + loadingBefore: false, + loadingAfter: false, + isAtBottom: true, + scrollToBottomRequested: false, + requestVersion: 0, }), + actions: { - async fetchMessages(channel_id: string) { + updateBoundaries(page: MessagePage) { + this.oldestId = page.oldest_id ?? this.messages[0]?.id ?? null; + this.newestId = page.newest_id ?? this.messages[this.messages.length - 1]?.id ?? null; + this.hasMoreBefore = page.has_more_before; + this.hasMoreAfter = page.has_more_after; + }, + + updateLocalBoundaries() { + this.oldestId = this.messages[0]?.id ?? null; + this.newestId = this.messages[this.messages.length - 1]?.id ?? null; + }, + + async fetchMessages(channelId: string) { + const requestVersion = ++this.requestVersion; + this.activeChannelId = channelId; + this.messages = []; + this.oldestId = null; + this.newestId = null; + this.hasMoreBefore = false; + this.hasMoreAfter = false; + this.isAtBottom = true; this.loading = true; - // Query params - let params = new URLSearchParams(); - params.append("channel_id", channel_id); - const queryString = params.toString(); - try { - const api = useApi(); - // Utilisation du paramètre pour cibler le channel - const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`); - this.messages = await response.json(); + const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE}); + if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return; + + this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE); + this.updateBoundaries(page); + this.scrollToBottomRequested = true; } catch (error) { - console.error("Erreur lors du chargement des messages:", error); + if (requestVersion === this.requestVersion) { + console.error("Erreur lors du chargement des messages:", error); + } } finally { - this.loading = false; + if (requestVersion === this.requestVersion) { + this.loading = false; + } } }, - async sendMessage(channelId: string, content: string) { - const api = useApi(); - console.log("channelId", channelId); - try { - // Envoi au serveur pour persistance - const response = await api.post('/messages', { - channel_id: channelId, - content: content, - reply_to_id: null - }); - const newMessage = await response.json(); - // Ajout local immédiat (optimistic update) - // this.messages.push(newMessage); + async fetchBefore(channelId: string): Promise { + if ( + this.activeChannelId !== channelId || + !this.oldestId || + !this.hasMoreBefore || + this.loadingBefore || + this.loadingAfter + ) return null; + + const requestVersion = this.requestVersion; + const previousIds = new Set(this.messages.map(message => message.id)); + this.loadingBefore = true; + + try { + const page = await requestPage(channelId, { + limit: MESSAGE_SHIFT_SIZE, + before_id: this.oldestId, + }); + if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null; + + const incoming = mergeMessages(page.messages); + const merged = mergeMessages([...incoming, ...this.messages]); + this.messages = merged.slice(0, MESSAGE_WINDOW_SIZE); + this.updateBoundaries(page); + this.oldestId = this.messages[0]?.id ?? null; + this.newestId = this.messages[this.messages.length - 1]?.id ?? null; + + return { + addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id), + removedIds: merged.slice(0, -MESSAGE_WINDOW_SIZE).map(message => message.id), + }; + } catch (error) { + console.error("Erreur lors du chargement des messages précédents:", error); + return null; + } finally { + if (requestVersion === this.requestVersion) { + this.loadingBefore = false; + } + } + }, + + async fetchAfter(channelId: string): Promise { + if ( + this.activeChannelId !== channelId || + !this.newestId || + !this.hasMoreAfter || + this.loadingBefore || + this.loadingAfter + ) return null; + + const requestVersion = this.requestVersion; + const previousIds = new Set(this.messages.map(message => message.id)); + this.loadingAfter = true; + + try { + const page = await requestPage(channelId, { + limit: MESSAGE_SHIFT_SIZE, + after_id: this.newestId, + }); + if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null; + + const incoming = mergeMessages(page.messages); + const merged = mergeMessages([...this.messages, ...incoming]); + this.messages = merged.slice(-MESSAGE_WINDOW_SIZE); + this.updateBoundaries(page); + this.oldestId = this.messages[0]?.id ?? null; + this.newestId = this.messages[this.messages.length - 1]?.id ?? null; + + return { + addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id), + removedIds: merged.slice(0, Math.max(0, merged.length - MESSAGE_WINDOW_SIZE)).map(message => message.id), + }; + } catch (error) { + console.error("Erreur lors du chargement des messages suivants:", error); + return null; + } finally { + if (requestVersion === this.requestVersion) { + this.loadingAfter = false; + } + } + }, + + async sendMessage(channelId: string, content: string) { + try { + const response = await useApi().post("/messages", { + channel_id: channelId, + content, + reply_to_id: null, + }); + if (!response.ok) { + throw new Error(`Message sending failed (${response.status})`); + } + + const newMessage = await response.json() as Message; + this.addRealtimeMessage(newMessage); + return newMessage; } catch (error) { console.error("Erreur lors de l'envoi du message:", error); throw error; } }, + + addRealtimeMessage(message: Message) { + if (message.channel_id !== this.activeChannelId) return; + + const existingIndex = this.messages.findIndex(current => current.id === message.id); + if (existingIndex !== -1) { + this.messages[existingIndex] = message; + return; + } + + if (!this.isAtBottom && this.newestId && message.id > this.newestId) { + this.hasMoreAfter = true; + return; + } + + this.messages = mergeMessages([...this.messages, message]).slice(-MESSAGE_WINDOW_SIZE); + this.updateLocalBoundaries(); + this.hasMoreAfter = false; + if (this.isAtBottom) { + this.scrollToBottomRequested = true; + } + }, + + updateMessage(message: Message) { + if (message.channel_id !== this.activeChannelId) return; + const index = this.messages.findIndex(current => current.id === message.id); + if (index !== -1) this.messages[index] = message; + }, + + removeMessage(id: string) { + const index = this.messages.findIndex(message => message.id === id); + if (index === -1) return; + this.messages.splice(index, 1); + this.updateLocalBoundaries(); + }, + + setAtBottom(value: boolean) { + this.isAtBottom = value; + }, + + consumeScrollToBottomRequest() { + const requested = this.scrollToBottomRequested; + this.scrollToBottomRequested = false; + return requested; + }, + reset() { + this.requestVersion += 1; this.messages = []; - } - } + this.activeChannelId = null; + this.oldestId = null; + this.newestId = null; + this.hasMoreBefore = false; + this.hasMoreAfter = false; + this.loading = false; + this.loadingBefore = false; + this.loadingAfter = false; + this.isAtBottom = true; + this.scrollToBottomRequested = false; + }, + }, }); onGatewayEvent("Message", (payload) => { const store = useMessageStore(); + switch (payload.action) { case "add": - const exists = store.messages.some(m => m.id === payload.content.id); - if (!exists) { - store.messages.push(payload.content); - } + store.addRealtimeMessage(payload.content as Message); break; case "update": - const updateIndex = store.messages.findIndex(m => m.id === payload.content.id); - if (updateIndex !== -1) { - store.messages[updateIndex] = payload.content; - } + store.updateMessage(payload.content as Message); break; case "remove": - const removeIndex = store.messages.findIndex(m => m.id === payload.content); - if (removeIndex !== -1) { - store.messages.splice(removeIndex, 1); - } + store.removeMessage(String(payload.content)); break; default: console.warn("Action non gérée :", payload.action); } -}) \ No newline at end of file +}); diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 865fb47..350eb4c 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -1,12 +1,16 @@ -pub use sea_orm_migration::prelude::*; - -mod m20220101_000001_create_table; - -pub struct Migrator; - -#[async_trait::async_trait] -impl MigratorTrait for Migrator { - fn migrations() -> Vec> { - vec![Box::new(m20220101_000001_create_table::Migration)] - } -} +pub use sea_orm_migration::prelude::*; + +mod m20220101_000001_create_table; +mod m20260808_000002_add_message_channel_id_id_index; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20220101_000001_create_table::Migration), + Box::new(m20260808_000002_add_message_channel_id_id_index::Migration), + ] + } +} diff --git a/migration/src/m20260808_000002_add_message_channel_id_id_index.rs b/migration/src/m20260808_000002_add_message_channel_id_id_index.rs new file mode 100644 index 0000000..e8f254d --- /dev/null +++ b/migration/src/m20260808_000002_add_message_channel_id_id_index.rs @@ -0,0 +1,31 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_index( + Index::create() + .name("idx_message_channel_id_id") + .table(Alias::new("message")) + .col(Alias::new("channel_id")) + .col(Alias::new("id")) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_message_channel_id_id") + .table(Alias::new("message")) + .to_owned(), + ) + .await + } +} diff --git a/scripts/generate_messages.py b/scripts/generate_messages.py new file mode 100644 index 0000000..764bb7e --- /dev/null +++ b/scripts/generate_messages.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Generate test messages directly in the project's SQLite database.""" + +from __future__ import annotations + +import argparse +import random +import sqlite3 +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path + + +WORD_POOL = ( + "message", "canal", "serveur", "utilisateur", "test", "donnee", "histoire", + "discussion", "contenu", "generation", "curseur", "fenetre", "lecture", + "chargement", "conversation", "exemple", "texte", "systeme", "application", + "client", "serveur", "base", "requete", "resultat", "information", "session", + "connexion", "fonction", "version", "contenu", "rapide", "simple", "aleatoire", + "important", "nouveau", "ancien", "prochain", "precedent", "visible", "local", + "distant", "stable", "chronologique", "variable", "longueur", "performance", + "validation", "operation", "transaction", "historique", "position", "defilement", +) +MESSAGE_MARKER_FORMAT = "[{number:04d}]" + + +def positive_int(value: str) -> int: + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return parsed + + +def parse_uuid(value: str, option_name: str) -> uuid.UUID: + try: + return uuid.UUID(value) + except ValueError as error: + raise argparse.ArgumentTypeError(f"{option_name} is not a valid UUID: {value}") from error + + +def next_uuid(previous: uuid.UUID | None) -> uuid.UUID: + """Return a UUID v7 strictly greater than the previous generated ID.""" + generated = uuid.uuid7() + if previous is not None and generated.int <= previous.int: + generated = uuid.UUID(int=previous.int + 1) + return generated + + +def random_message( + rng: random.Random, + min_words: int, + max_words: int, + marker: str, +) -> str: + # The marker itself counts as one word in the requested range. + body_count = rng.randint(max(0, min_words - 1), max_words - 1) + body = " ".join(rng.choices(WORD_POOL, k=body_count)) + return f"{marker} {body}".rstrip() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--db", + type=Path, + default=Path("oxspeak.db"), + help="SQLite database path (default: oxspeak.db)", + ) + parser.add_argument("--channel-id", required=True, help="target channel UUID") + parser.add_argument("--user-id", required=True, help="author user UUID") + parser.add_argument( + "--count", + required=True, + type=positive_int, + help="number of messages to insert", + ) + parser.add_argument( + "--min-words", + type=positive_int, + default=10, + help="minimum number of words per message (default: 10)", + ) + parser.add_argument( + "--max-words", + type=positive_int, + default=500, + help="maximum number of words per message (default: 500)", + ) + parser.add_argument( + "--seed", + type=int, + default=None, + help="optional seed to reproduce generated contents", + ) + parser.add_argument( + "--batch-size", + type=positive_int, + default=500, + help="number of rows inserted per batch (default: 500)", + ) + return parser + + +def ensure_target_exists( + connection: sqlite3.Connection, + table: str, + identifier: bytes, + label: str, +) -> None: + row = connection.execute( + f'SELECT 1 FROM "{table}" WHERE id = ? LIMIT 1', + (identifier,), + ).fetchone() + if row is None: + raise ValueError(f"{label} does not exist in the database") + + +def generate_messages( + database: Path, + channel_id: uuid.UUID, + user_id: uuid.UUID, + count: int, + batch_size: int, + min_words: int, + max_words: int, + seed: int | None, +) -> None: + started_at = time.monotonic() + connection = sqlite3.connect(database) + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 5000") + + try: + ensure_target_exists(connection, "channel", channel_id.bytes, "channel") + ensure_target_exists(connection, "user", user_id.bytes, "user") + + previous_id: uuid.UUID | None = None + inserted = 0 + rng = random.Random(seed) + + connection.execute("BEGIN") + try: + while inserted < count: + current_batch_size = min(batch_size, count - inserted) + rows = [] + + for offset in range(current_batch_size): + message_id = next_uuid(previous_id) + previous_id = message_id + message_number = inserted + offset + 1 + marker = MESSAGE_MARKER_FORMAT.format(number=message_number) + content = random_message(rng, min_words, max_words, marker) + created_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + rows.append( + ( + message_id.bytes, + channel_id.bytes, + user_id.bytes, + content, + created_at, + None, + None, + ) + ) + + connection.executemany( + """ + INSERT INTO message + (id, channel_id, user_id, content, created_at, updated_at, reply_to_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + inserted += current_batch_size + + connection.commit() + except Exception: + connection.rollback() + raise + finally: + connection.close() + + elapsed = time.monotonic() - started_at + print(f"Inserted {count} messages into {database} in {elapsed:.2f}s") + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.max_words < args.min_words: + parser.error("--max-words must be greater than or equal to --min-words") + + try: + channel_id = parse_uuid(args.channel_id, "--channel-id") + user_id = parse_uuid(args.user_id, "--user-id") + generate_messages( + database=args.db, + channel_id=channel_id, + user_id=user_id, + count=args.count, + batch_size=args.batch_size, + min_words=args.min_words, + max_words=args.max_words, + seed=args.seed, + ) + except (OSError, sqlite3.Error, ValueError) as error: + parser.error(str(error)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/domain/dto/message.rs b/src/domain/dto/message.rs index 646055d..7ed5333 100644 --- a/src/domain/dto/message.rs +++ b/src/domain/dto/message.rs @@ -14,6 +14,15 @@ pub struct MessageResponse { pub reply_to_id: Option, } +#[derive(Debug, Serialize, ToSchema)] +pub struct MessagePageResponse { + pub messages: Vec, + pub oldest_id: Option, + pub newest_id: Option, + pub has_more_before: bool, + pub has_more_after: bool, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateMessageRequest { pub channel_id: Uuid, @@ -26,19 +35,10 @@ pub struct UpdateMessageRequest { pub content: String, } -#[derive(serde::Deserialize, utoipa::IntoParams)] +#[derive(Debug, serde::Deserialize, utoipa::IntoParams)] pub struct MessageQueryParams { - pub channel_id: Option, + pub channel_id: Uuid, pub before_id: Option, + pub after_id: Option, pub limit: Option, } - -impl Default for MessageQueryParams { - fn default() -> Self { - Self { - channel_id: None, - before_id: None, - limit: Some(50), - } - } -} diff --git a/src/models/mod.rs b/src/models/mod.rs index c8815a8..f13e722 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,20 +1,20 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 - -pub mod prelude; - -pub mod attachment; -pub mod category; -pub mod channel; -pub mod channel_role_permission; -pub mod channel_user; -pub mod channel_user_permission; -pub mod computed_permission; -pub mod message; -pub mod role; -pub mod role_user; -pub mod server; -pub mod server_item_order; -pub mod server_role_permission; -pub mod server_user; -pub mod server_user_permission; -pub mod user; +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +pub mod prelude; + +pub mod attachment; +pub mod category; +pub mod channel; +pub mod channel_role_permission; +pub mod channel_user; +pub mod channel_user_permission; +pub mod computed_permission; +pub mod message; +pub mod role; +pub mod role_user; +pub mod server; +pub mod server_item_order; +pub mod server_role_permission; +pub mod server_user; +pub mod server_user_permission; +pub mod user; diff --git a/src/models/prelude.rs b/src/models/prelude.rs index 1bd2091..1ceec3f 100644 --- a/src/models/prelude.rs +++ b/src/models/prelude.rs @@ -1,16 +1,16 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 - -pub use super::attachment::Entity as Attachment; -pub use super::category::Entity as Category; -pub use super::channel::Entity as Channel; -pub use super::channel_user::Entity as ChannelUser; -pub use super::computed_permission::Entity as ComputedPermission; -pub use super::message::Entity as Message; -pub use super::role::Entity as Group; -pub use super::role_user::Entity as GroupMember; -pub use super::server::Entity as Server; -pub use super::server_item_order::Entity as ServerItemOrder; -pub use super::server_role_permission::Entity as ServerRolePermission; -pub use super::server_user::Entity as ServerUser; -pub use super::server_user_permission::Entity as ServerUserPermission; -pub use super::user::Entity as User; +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +pub use super::attachment::Entity as Attachment; +pub use super::category::Entity as Category; +pub use super::channel::Entity as Channel; +pub use super::channel_user::Entity as ChannelUser; +pub use super::computed_permission::Entity as ComputedPermission; +pub use super::message::Entity as Message; +pub use super::role::Entity as Group; +pub use super::role_user::Entity as GroupMember; +pub use super::server::Entity as Server; +pub use super::server_item_order::Entity as ServerItemOrder; +pub use super::server_role_permission::Entity as ServerRolePermission; +pub use super::server_user::Entity as ServerUser; +pub use super::server_user_permission::Entity as ServerUserPermission; +pub use super::user::Entity as User; diff --git a/src/repositories/message.rs b/src/repositories/message.rs index 1a0ef14..605f4ce 100644 --- a/src/repositories/message.rs +++ b/src/repositories/message.rs @@ -1,9 +1,18 @@ use super::types::MessageFilter; -use crate::models::{channel, message}; +use crate::models::message; use crate::repositories::{AnyResult, RepositoryContext}; -use event_bus::Scope; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use std::sync::Arc; +use uuid::Uuid; + +const DEFAULT_MESSAGE_LIMIT: u64 = 20; +pub const MAX_MESSAGE_LIMIT: u64 = 100; + +pub struct MessagePage { + pub messages: Vec, + pub has_more_before: bool, + pub has_more_after: bool, +} #[derive(Clone, Debug)] pub struct MessageRepository { @@ -21,7 +30,11 @@ impl MessageRepository { .await?) } - pub async fn filter(&self, filter: MessageFilter) -> AnyResult> { + pub async fn filter(&self, filter: MessageFilter) -> AnyResult { + let limit = filter + .limit + .unwrap_or(DEFAULT_MESSAGE_LIMIT) + .clamp(1, MAX_MESSAGE_LIMIT); let mut query = message::Entity::find(); if let Some(channel_id) = filter.channel_id { @@ -32,11 +45,80 @@ impl MessageRepository { query = query.filter(message::Column::Id.lt(before_id)); } - if let Some(limit) = filter.limit { - query = query.order_by_desc(message::Column::Id).limit(limit); + if let Some(after_id) = filter.after_id { + query = query + .filter(message::Column::Id.gt(after_id)) + .order_by_asc(message::Column::Id); + } else { + query = query.order_by_desc(message::Column::Id); } - Ok(query.all(&self.context.db).await?) + let mut messages = query.limit(limit + 1).all(&self.context.db).await?; + let has_more_in_direction = messages.len() > limit as usize; + messages.truncate(limit as usize); + + // Queries that walk backwards are executed in descending order so the + // database can stop as soon as it has found the requested rows. The UI + // always receives chronological order. + if filter.after_id.is_none() { + messages.reverse(); + } + + let (has_more_before, has_more_after) = if filter.after_id.is_some() { + let has_messages_before = self + .exists_on_or_before(filter.channel_id, filter.after_id.unwrap()) + .await?; + (has_messages_before, has_more_in_direction) + } else if filter.before_id.is_some() { + let has_messages_after = self + .exists_on_or_after(filter.channel_id, filter.before_id.unwrap()) + .await?; + (has_more_in_direction, has_messages_after) + } else { + (has_more_in_direction, false) + }; + + Ok(MessagePage { + messages, + has_more_before, + has_more_after, + }) + } + + async fn exists_on_or_before(&self, channel_id: Option, id: Uuid) -> AnyResult { + let mut query = message::Entity::find() + .select_only() + .column(message::Column::Id) + .filter(message::Column::Id.lte(id)); + + if let Some(channel_id) = channel_id { + query = query.filter(message::Column::ChannelId.eq(channel_id)); + } + + Ok(query + .limit(1) + .into_tuple::() + .one(&self.context.db) + .await? + .is_some()) + } + + async fn exists_on_or_after(&self, channel_id: Option, id: Uuid) -> AnyResult { + let mut query = message::Entity::find() + .select_only() + .column(message::Column::Id) + .filter(message::Column::Id.gte(id)); + + if let Some(channel_id) = channel_id { + query = query.filter(message::Column::ChannelId.eq(channel_id)); + } + + Ok(query + .limit(1) + .into_tuple::() + .one(&self.context.db) + .await? + .is_some()) } pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult> { diff --git a/src/repositories/types.rs b/src/repositories/types.rs index 92c20ca..5f9a863 100644 --- a/src/repositories/types.rs +++ b/src/repositories/types.rs @@ -24,6 +24,7 @@ pub struct ServerTree { pub struct MessageFilter { pub channel_id: Option, pub before_id: Option, + pub after_id: Option, pub limit: Option, } diff --git a/src/routes/message/handlers.rs b/src/routes/message/handlers.rs index 9402ce2..8d1aadb 100644 --- a/src/routes/message/handlers.rs +++ b/src/routes/message/handlers.rs @@ -1,21 +1,25 @@ -use crate::domain::dto::message::{CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest}; use crate::core::state::AppState; +use crate::domain::dto::message::{ + CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse, + UpdateMessageRequest, +}; use crate::http::context::CurrentUser; use crate::http::error::HTTPError; use crate::routes::message::mapper; use axum::{ + Json, extract::{Path, Query, State}, http::StatusCode, - Json, }; use uuid::Uuid; -/// Liste tous les messages +/// Liste une fenêtre paginée de messages #[utoipa::path( get, path = "/messages", responses( - (status = 200, description = "Liste des messages récupérée avec succès", body = [MessageResponse]), + (status = 200, description = "Fenêtre de messages récupérée avec succès", body = MessagePageResponse), + (status = 400, description = "Curseurs incompatibles ou canal manquant"), (status = 500, description = "Erreur interne du serveur") ), params( @@ -26,15 +30,29 @@ use uuid::Uuid; pub async fn get_all( State(state): State, Query(filters): Query, -) -> Result>, HTTPError> { +) -> Result, HTTPError> { + if filters.before_id.is_some() && filters.after_id.is_some() { + return Err(HTTPError::BadRequest( + "before_id and after_id cannot be used together".to_string(), + )); + } + let params = mapper::query_params_to_message_filter(filters); - let messages = state.repositories.message.filter(params).await?; - Ok(Json( - messages + let page = state.repositories.message.filter(params).await?; + let oldest_id = page.messages.first().map(|message| message.id); + let newest_id = page.messages.last().map(|message| message.id); + + Ok(Json(MessagePageResponse { + messages: page + .messages .into_iter() .map(mapper::message_model_to_message_response) .collect(), - )) + oldest_id, + newest_id, + has_more_before: page.has_more_before, + has_more_after: page.has_more_after, + })) } /// Récupère un message par son ID @@ -105,7 +123,11 @@ pub async fn create( ))?; } - let message = state.services.message.create_message(payload.channel_id, user.id, payload.content).await?; + let message = state + .services + .message + .create_message(payload.channel_id, user.id, payload.content) + .await?; Ok(( StatusCode::CREATED, Json(mapper::message_model_to_message_response(message)), @@ -150,7 +172,11 @@ pub async fn update( return Err(HTTPError::Forbidden); } - let message = state.services.message.update_message(id, payload.content).await?; + let message = state + .services + .message + .update_message(id, payload.content) + .await?; Ok(Json(mapper::message_model_to_message_response(message))) } diff --git a/src/routes/message/mapper.rs b/src/routes/message/mapper.rs index 243b1ab..ddaa670 100644 --- a/src/routes/message/mapper.rs +++ b/src/routes/message/mapper.rs @@ -1,8 +1,8 @@ -use crate::models::message; -use crate::repositories::types::MessageFilter; use crate::domain::dto::message::{ CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest, }; +use crate::models::message; +use crate::repositories::types::MessageFilter; use chrono::Utc; use sea_orm::Set; use uuid::Uuid; @@ -48,8 +48,9 @@ pub fn update_request_to_am( pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter { MessageFilter { - channel_id: params.channel_id, + channel_id: Some(params.channel_id), before_id: params.before_id, + after_id: params.after_id, limit: params.limit, } }