added server-side user unread_count to rooms
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "frangipane"
|
name = "frangipane"
|
||||||
version = "1.0.1"
|
version = "1.0.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
63
db/init.sql
63
db/init.sql
@@ -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();
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
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 != $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,
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user