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]]
name = "frangipane"
version = "1.0.1"
version = "1.0.2"
dependencies = [
"anyhow",
"argon2",

View File

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

View File

@@ -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();

View File

@@ -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<Uuid>,
Query(query): Query<MessageFetchQuery>,
@@ -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))
}

View File

@@ -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<i64>,
is_member: Option<bool>,
}
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,
}),
))
}