From 96837fcc212091857ce1701b38feff0705ff7e90 Mon Sep 17 00:00:00 2001 From: eiiko6 Date: Fri, 16 Jan 2026 12:39:00 +0100 Subject: [PATCH] added server-side user unread_count to rooms --- Cargo.lock | 2 +- Cargo.toml | 2 +- db/init.sql | 63 ++++++++++++++++++++----------- src/routes/messages.rs | 32 +++++++++++++++- src/routes/rooms.rs | 85 +++++++++++++++++++++++++++++------------- 5 files changed, 133 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0644347..e40c45f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,7 +619,7 @@ dependencies = [ [[package]] name = "frangipane" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 3b5b476..be87638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frangipane" -version = "1.0.1" +version = "1.0.2" edition = "2024" [dependencies] diff --git a/db/init.sql b/db/init.sql index 44462db..8864e7a 100644 --- a/db/init.sql +++ b/db/init.sql @@ -10,14 +10,14 @@ CREATE TABLE IF NOT EXISTS user_ ( CREATE TABLE IF NOT EXISTS friendship_ ( user_first INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, user_second INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - created_at TIMESTAMP NOT NULL DEFAULT now(), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_first, user_second) ); CREATE TABLE IF NOT EXISTS friend_request_ ( sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - sent_at TIMESTAMP NOT NULL DEFAULT now(), + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (sender, receiver), CHECK (sender <> receiver) ); @@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS room_ ( CREATE TABLE IF NOT EXISTS membership_ ( user_id INT REFERENCES user_(id), room INT REFERENCES room_(id), + last_read_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, room) ); @@ -40,7 +41,7 @@ CREATE TABLE IF NOT EXISTS room_invite_ ( sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, room INT NOT NULL, - sent_at TIMESTAMP NOT NULL DEFAULT now(), + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (sender, receiver), CHECK (sender <> receiver) ); @@ -52,7 +53,7 @@ CREATE TABLE IF NOT EXISTS message_ ( room INT REFERENCES room_(id) NOT NULL, message_type VARCHAR(32) NOT NULL, content TEXT NOT NULL, - sent_at TIMESTAMP NOT NULL DEFAULT now() + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE ws_token_ ( @@ -60,22 +61,40 @@ CREATE TABLE ws_token_ ( expires_at TIMESTAMPTZ NOT NULL ); +-- ==== INDICES ==== +CREATE INDEX idx_message_room_sent_at ON message_ (room, sent_at); +CREATE UNIQUE INDEX idx_membership_user_room ON membership_ (user_id, room) INCLUDE (last_read_at); + -- Timestamp creation -CREATE OR REPLACE FUNCTION create_notification_timestamp() -RETURNS trigger -AS $$ -BEGIN - NEW.sent_at := CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE TRIGGER insert_message -BEFORE INSERT ON message_ -FOR EACH ROW -EXECUTE FUNCTION create_notification_timestamp(); - -CREATE OR REPLACE TRIGGER insert_room_invite -BEFORE INSERT ON room_invite_ -FOR EACH ROW -EXECUTE FUNCTION create_notification_timestamp(); +-- CREATE OR REPLACE FUNCTION create_notification_timestamp() +-- RETURNS trigger +-- AS $$ +-- BEGIN +-- NEW.sent_at := CURRENT_TIMESTAMP; +-- RETURN NEW; +-- END; +-- $$ LANGUAGE plpgsql; +-- +-- CREATE OR REPLACE TRIGGER insert_message +-- BEFORE INSERT ON message_ +-- FOR EACH ROW +-- EXECUTE FUNCTION create_notification_timestamp(); +-- +-- CREATE OR REPLACE TRIGGER insert_room_invite +-- BEFORE INSERT ON room_invite_ +-- FOR EACH ROW +-- EXECUTE FUNCTION create_notification_timestamp(); +-- +-- CREATE OR REPLACE FUNCTION create_membership_timestamp() +-- RETURNS trigger +-- AS $$ +-- BEGIN +-- NEW.last_read_at = CURRENT_TIMESTAMP; +-- RETURN NEW; +-- END; +-- $$ LANGUAGE plpgsql; +-- +-- CREATE OR REPLACE TRIGGER insert_membership +-- BEFORE INSERT ON membership_ +-- FOR EACH ROW +-- EXECUTE FUNCTION create_membership_timestamp(); diff --git a/src/routes/messages.rs b/src/routes/messages.rs index 31b5780..dc4f843 100644 --- a/src/routes/messages.rs +++ b/src/routes/messages.rs @@ -53,6 +53,7 @@ pub fn routes() -> Router { .route("/messages/{room_uuid}", post(create_message)) } +/// Also resets `last_read_at` async fn list_messages( Path(room_uuid): Path, Query(query): Query, @@ -73,6 +74,11 @@ async fn list_messages( let limit: i32 = query.limit.unwrap_or(30).abs().min(80); + let mut tx = db + .begin() + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "DB error".into()))?; + let messages = sqlx::query_as::<_, MessageRow>( r#" SELECT @@ -95,7 +101,7 @@ async fn list_messages( .bind(room_id) .bind(query.before) .bind(limit) - .fetch_all(&db) + .fetch_all(&mut *tx) .await .map_err(|e| { ( @@ -119,6 +125,30 @@ async fn list_messages( messages.reverse(); + sqlx::query( + r#" + UPDATE membership_ + SET last_read_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND room = $2 + "#, + ) + .bind(user_id) + .bind(room_id) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Error updating membership timestamp: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to ")) + })?; + + tx.commit().await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Could not list messages".into(), + ) + })?; + Ok(Json(messages)) } diff --git a/src/routes/rooms.rs b/src/routes/rooms.rs index 562f07c..7395320 100644 --- a/src/routes/rooms.rs +++ b/src/routes/rooms.rs @@ -17,6 +17,7 @@ pub struct Room { pub global: bool, pub owner_name: String, pub owner_uuid: Uuid, + pub unread_count: i64, } #[derive(serde::Deserialize)] @@ -89,24 +90,32 @@ async fn list_rooms( let rooms = sqlx::query_as::<_, Room>( r#" - SELECT r.uuid, - u.username AS owner_name, - u.uuid AS owner_uuid, - r.name, - r.global + SELECT + r.uuid, + u.username AS owner_name, + u.uuid AS owner_uuid, + r.name, + r.global, + ( + SELECT COUNT(*) + FROM message_ m + WHERE m.room = r.id + AND m.sent_at > mem.last_read_at + AND m.sender != $1 + ) AS unread_count FROM room_ r JOIN user_ u ON u.id = r.owner - WHERE r.global OR EXISTS ( - SELECT 1 - FROM membership_ m - WHERE m.user_id = $1 AND m.room = r.id - ) + LEFT JOIN membership_ mem ON mem.room = r.id AND mem.user_id = $1 + WHERE r.global OR mem.user_id IS NOT NULL "#, ) .bind(user_id) .fetch_all(&db) .await - .unwrap_or(Vec::new()); + .unwrap_or_else(|e| { + tracing::error!("Failed listing rooms: {e}"); + Vec::new() + }); Ok(Json(rooms)) } @@ -158,6 +167,7 @@ async fn create_room( owner_uuid: claims.sub, name: payload.name, global: payload.global, + unread_count: 0, }), )) } @@ -170,42 +180,63 @@ async fn get_room( let claims = verify_jwt(headers)?; let user_id = user_id_from_uuid(&db, claims.sub).await?; - let room_id = room_id_from_uuid(&db, room_uuid).await?; - if !is_member(user_id, room_id, &db).await { - return Err(( - StatusCode::FORBIDDEN, - String::from("You are not a member of this room"), - )); + #[derive(sqlx::FromRow)] + struct RoomRow { + uuid: Uuid, + owner_name: String, + owner_uuid: Uuid, + name: String, + global: bool, + unread_count: Option, + is_member: Option, } - let room: Room = sqlx::query_as( + let row: RoomRow = sqlx::query_as( r#" SELECT r.uuid, u.username AS owner_name, u.uuid AS owner_uuid, r.name, - r.global + r.global, + ( + SELECT COUNT(*) + FROM message_ m + WHERE m.room = r.id + AND m.sent_at > mem.last_read_at + AND m.sender != $2 + ) AS unread_count, + (r.global OR mem.user_id IS NOT NULL) AS is_member FROM room_ r JOIN user_ u ON u.id = r.owner + LEFT JOIN membership_ mem ON mem.room = r.id AND mem.user_id = $2 WHERE r.uuid = $1 "#, ) .bind(room_uuid) + .bind(user_id) .fetch_one(&db) .await .map_err(|e| { - tracing::error!("{e}"); + tracing::error!("Failed getting room: {e}"); (StatusCode::NOT_FOUND, "Room not found".to_string()) })?; + if !row.is_member.unwrap_or(false) { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room".to_string(), + )); + } + Ok(Json(Room { - uuid: room_uuid, - owner_name: room.owner_name, - owner_uuid: room.owner_uuid, - name: room.name, - global: room.global, + uuid: row.uuid, + owner_name: row.owner_name, + owner_uuid: row.owner_uuid, + name: row.name, + global: row.global, + unread_count: row.unread_count.unwrap_or(0), })) } @@ -362,7 +393,8 @@ async fn accept_request( u.username AS owner_name, u.uuid AS owner_uuid, r.name, - r.global + r.global, + 0::bigint AS unread_count FROM room_ r JOIN user_ u ON u.id = r.owner WHERE r.id = $1 AND r.owner = $2 @@ -394,6 +426,7 @@ async fn accept_request( owner_uuid: room.owner_uuid, name: room.name, global: room.global, + unread_count: room.unread_count, }), )) }