added server-side user unread_count to rooms

This commit is contained in:
2026-01-16 12:39:00 +01:00
parent 37e6bb25fc
commit 96837fcc21
5 changed files with 133 additions and 51 deletions

2
Cargo.lock generated
View File

@@ -619,7 +619,7 @@ dependencies = [
[[package]] [[package]]
name = "frangipane" name = "frangipane"
version = "1.0.1" version = "1.0.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "frangipane" name = "frangipane"
version = "1.0.1" version = "1.0.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -10,14 +10,14 @@ CREATE TABLE IF NOT EXISTS user_ (
CREATE TABLE IF NOT EXISTS friendship_ ( CREATE TABLE IF NOT EXISTS friendship_ (
user_first INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, user_first INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE,
user_second 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) PRIMARY KEY (user_first, user_second)
); );
CREATE TABLE IF NOT EXISTS friend_request_ ( CREATE TABLE IF NOT EXISTS friend_request_ (
sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE,
receiver 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), PRIMARY KEY (sender, receiver),
CHECK (sender <> receiver) CHECK (sender <> receiver)
); );
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS room_ (
CREATE TABLE IF NOT EXISTS membership_ ( CREATE TABLE IF NOT EXISTS membership_ (
user_id INT REFERENCES user_(id), user_id INT REFERENCES user_(id),
room INT REFERENCES room_(id), room INT REFERENCES room_(id),
last_read_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, room) 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, sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE,
receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE,
room INT NOT NULL, room INT NOT NULL,
sent_at TIMESTAMP NOT NULL DEFAULT now(), sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (sender, receiver), PRIMARY KEY (sender, receiver),
CHECK (sender <> receiver) CHECK (sender <> receiver)
); );
@@ -52,7 +53,7 @@ CREATE TABLE IF NOT EXISTS message_ (
room INT REFERENCES room_(id) NOT NULL, room INT REFERENCES room_(id) NOT NULL,
message_type VARCHAR(32) NOT NULL, message_type VARCHAR(32) NOT NULL,
content TEXT 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_ ( CREATE TABLE ws_token_ (
@@ -60,22 +61,40 @@ CREATE TABLE ws_token_ (
expires_at TIMESTAMPTZ NOT NULL 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 -- Timestamp creation
CREATE OR REPLACE FUNCTION create_notification_timestamp() -- CREATE OR REPLACE FUNCTION create_notification_timestamp()
RETURNS trigger -- RETURNS trigger
AS $$ -- AS $$
BEGIN -- BEGIN
NEW.sent_at := CURRENT_TIMESTAMP; -- NEW.sent_at := CURRENT_TIMESTAMP;
RETURN NEW; -- RETURN NEW;
END; -- END;
$$ LANGUAGE plpgsql; -- $$ LANGUAGE plpgsql;
--
CREATE OR REPLACE TRIGGER insert_message -- CREATE OR REPLACE TRIGGER insert_message
BEFORE INSERT ON message_ -- BEFORE INSERT ON message_
FOR EACH ROW -- FOR EACH ROW
EXECUTE FUNCTION create_notification_timestamp(); -- EXECUTE FUNCTION create_notification_timestamp();
--
CREATE OR REPLACE TRIGGER insert_room_invite -- CREATE OR REPLACE TRIGGER insert_room_invite
BEFORE INSERT ON room_invite_ -- BEFORE INSERT ON room_invite_
FOR EACH ROW -- FOR EACH ROW
EXECUTE FUNCTION create_notification_timestamp(); -- 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();

View File

@@ -53,6 +53,7 @@ pub fn routes() -> Router {
.route("/messages/{room_uuid}", post(create_message)) .route("/messages/{room_uuid}", post(create_message))
} }
/// Also resets `last_read_at`
async fn list_messages( async fn list_messages(
Path(room_uuid): Path<Uuid>, Path(room_uuid): Path<Uuid>,
Query(query): Query<MessageFetchQuery>, Query(query): Query<MessageFetchQuery>,
@@ -73,6 +74,11 @@ async fn list_messages(
let limit: i32 = query.limit.unwrap_or(30).abs().min(80); 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>( let messages = sqlx::query_as::<_, MessageRow>(
r#" r#"
SELECT SELECT
@@ -95,7 +101,7 @@ async fn list_messages(
.bind(room_id) .bind(room_id)
.bind(query.before) .bind(query.before)
.bind(limit) .bind(limit)
.fetch_all(&db) .fetch_all(&mut *tx)
.await .await
.map_err(|e| { .map_err(|e| {
( (
@@ -119,6 +125,30 @@ async fn list_messages(
messages.reverse(); 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)) Ok(Json(messages))
} }

View File

@@ -17,6 +17,7 @@ pub struct Room {
pub global: bool, pub global: bool,
pub owner_name: String, pub owner_name: String,
pub owner_uuid: Uuid, pub owner_uuid: Uuid,
pub unread_count: i64,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -89,24 +90,32 @@ async fn list_rooms(
let rooms = sqlx::query_as::<_, Room>( let rooms = sqlx::query_as::<_, Room>(
r#" r#"
SELECT r.uuid, SELECT
u.username AS owner_name, r.uuid,
u.uuid AS owner_uuid, u.username AS owner_name,
r.name, u.uuid AS owner_uuid,
r.global 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 FROM room_ r
JOIN user_ u ON u.id = r.owner JOIN user_ u ON u.id = r.owner
WHERE r.global OR EXISTS ( LEFT JOIN membership_ mem ON mem.room = r.id AND mem.user_id = $1
SELECT 1 WHERE r.global OR mem.user_id IS NOT NULL
FROM membership_ m
WHERE m.user_id = $1 AND m.room = r.id
)
"#, "#,
) )
.bind(user_id) .bind(user_id)
.fetch_all(&db) .fetch_all(&db)
.await .await
.unwrap_or(Vec::new()); .unwrap_or_else(|e| {
tracing::error!("Failed listing rooms: {e}");
Vec::new()
});
Ok(Json(rooms)) Ok(Json(rooms))
} }
@@ -158,6 +167,7 @@ async fn create_room(
owner_uuid: claims.sub, owner_uuid: claims.sub,
name: payload.name, name: payload.name,
global: payload.global, global: payload.global,
unread_count: 0,
}), }),
)) ))
} }
@@ -170,42 +180,63 @@ async fn get_room(
let claims = verify_jwt(headers)?; let claims = verify_jwt(headers)?;
let user_id = user_id_from_uuid(&db, claims.sub).await?; 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 { #[derive(sqlx::FromRow)]
return Err(( struct RoomRow {
StatusCode::FORBIDDEN, uuid: Uuid,
String::from("You are not a member of this room"), owner_name: String,
)); owner_uuid: Uuid,
name: String,
global: bool,
unread_count: Option<i64>,
is_member: Option<bool>,
} }
let room: Room = sqlx::query_as( let row: RoomRow = sqlx::query_as(
r#" r#"
SELECT SELECT
r.uuid, r.uuid,
u.username AS owner_name, u.username AS owner_name,
u.uuid AS owner_uuid, u.uuid AS owner_uuid,
r.name, 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 FROM room_ r
JOIN user_ u ON u.id = r.owner 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 WHERE r.uuid = $1
"#, "#,
) )
.bind(room_uuid) .bind(room_uuid)
.bind(user_id)
.fetch_one(&db) .fetch_one(&db)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("{e}"); tracing::error!("Failed getting room: {e}");
(StatusCode::NOT_FOUND, "Room not found".to_string()) (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 { Ok(Json(Room {
uuid: room_uuid, uuid: row.uuid,
owner_name: room.owner_name, owner_name: row.owner_name,
owner_uuid: room.owner_uuid, owner_uuid: row.owner_uuid,
name: room.name, name: row.name,
global: room.global, 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.username AS owner_name,
u.uuid AS owner_uuid, u.uuid AS owner_uuid,
r.name, r.name,
r.global r.global,
0::bigint AS unread_count
FROM room_ r FROM room_ r
JOIN user_ u ON u.id = r.owner JOIN user_ u ON u.id = r.owner
WHERE r.id = $1 AND r.owner = $2 WHERE r.id = $1 AND r.owner = $2
@@ -394,6 +426,7 @@ async fn accept_request(
owner_uuid: room.owner_uuid, owner_uuid: room.owner_uuid,
name: room.name, name: room.name,
global: room.global, global: room.global,
unread_count: room.unread_count,
}), }),
)) ))
} }