Skip to main content

arminius_lib_db_read_receipts/
lib.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Queries related to the `read_receipts` table.
6
7use std::{collections::BTreeMap, time::SystemTime};
8
9use arminius_lib_db::{Database, DatabaseError, ResultExt as _};
10use arminius_lib_model::{
11	read_receipts::{DbReceiptType, SyncReceiptsMap},
12	types::StreamId,
13};
14use ruma::{
15	EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, RoomId, UserId,
16	api::client::filter::RoomEventFilter,
17	events::receipt::{Receipt, ReceiptThread},
18};
19
20/// A read receipt stored in the database.
21#[derive(Debug)]
22pub struct ReadReceiptRow {
23	/// The unique ID of the receipt. This is used in order to delete older
24	/// receipts.
25	pub nid: i32,
26	/// The event the receipt acknowledges up to.
27	pub event_id: OwnedEventId,
28	/// The type of receipt (public or private).
29	pub receipt_type: DbReceiptType,
30	/// The thread the receipt applies to.
31	pub thread: ReceiptThread,
32	/// Ordering of the event. See the `22_event_ordering.sql` migration.
33	pub event_ordering: Vec<i64>,
34	/// Timestamp at which the user
35	pub ts: chrono::DateTime<chrono::Utc>,
36}
37
38/// Read receipts fetched for syncing.
39#[derive(Debug)]
40pub struct SyncRoomReceipts {
41	/// The maximum read receipt stream ID found in the fetched receipts.
42	pub max_stream_id: StreamId,
43	/// The actual read receipts map.
44	pub receipts: SyncReceiptsMap,
45}
46
47/// Trait declaring SQL queries related to the `read_receipts` table.
48// Queries are usually used as extension traits so suggest `import as _`.
49#[rust_analyzer::prefer_underscore_import]
50pub trait ReadReceiptsQueries: Database {
51	/// Fetches all the read receipts of an user in a room. Optionally filter in
52	/// only private receipts.
53	#[tracing::instrument(skip(self))]
54	async fn fetch_read_receipts(
55		&mut self,
56		room_id: &RoomId,
57		user_id: &UserId,
58		only_private_receipts: bool,
59	) -> Result<Vec<ReadReceiptRow>, DatabaseError> {
60		sqlx::query!(
61			r#"
62			SELECT
63				read_receipts.nid,
64				events.event_id,
65				read_receipts.receipt_type AS "receipt_type:DbReceiptType",
66				read_receipts.thread,
67				event_ordering.ordering,
68				read_receipts.ts
69			FROM
70				read_receipts
71				JOIN rooms ON read_receipts.room_nid = rooms.nid
72				JOIN events ON read_receipts.event_nid = events.nid
73				JOIN event_ordering ON event_ordering.event_nid = events.nid
74			WHERE
75				rooms.room_id = $1
76				AND read_receipts.user_id = $2
77				AND (
78					CASE WHEN $3::bool IS TRUE THEN
79						read_receipts.receipt_type = 'm.read.private'
80					ELSE
81						TRUE
82					END)
83			ORDER BY
84				event_ordering.ordering DESC
85			"#,
86			room_id.to_string(),
87			user_id.to_string(),
88			only_private_receipts
89		)
90		.fetch_all(self.connection().await?)
91		.await?
92		.into_iter()
93		.map(|row| {
94			let event_id =
95				EventId::parse(row.event_id).invalid_data("Invalid event ID in database")?;
96
97			let thread = ReceiptThread::try_from(row.thread)
98				.invalid_data("Invalid read receipt thread in database")?;
99
100			Ok(ReadReceiptRow {
101				nid: row.nid,
102				event_id,
103				receipt_type: row.receipt_type,
104				thread,
105				event_ordering: row.ordering,
106				ts: row.ts,
107			})
108		})
109		.collect::<Result<Vec<_>, _>>()
110	}
111
112	/// Inserts a new read receipt in the database.
113	#[expect(
114		trivial_casts,
115		reason = "The casts in `sqlx` queries are not regular rust casts, but used to infer custom
116		types from SQL queries. See
117		<https://docs.rs/sqlx/latest/sqlx/macro.query.html#type-overrides-bind-parameters-postgres-only>"
118	)]
119	#[tracing::instrument(skip(self))]
120	async fn insert_read_receipt(
121		&mut self,
122		room_id: &RoomId,
123		user_id: &UserId,
124		event_id: &EventId,
125		receipt_type: DbReceiptType,
126		thread: &ReceiptThread,
127		ts: chrono::DateTime<chrono::Utc>,
128	) -> Result<(), DatabaseError> {
129		sqlx::query!(
130			"
131			INSERT INTO read_receipts
132				(room_nid, user_id, event_nid, receipt_type, thread, ts)
133			VALUES (
134				(SELECT nid FROM rooms WHERE room_id = $1),
135				$2,
136				(SELECT nid FROM events WHERE event_id = $3),
137				$4,
138				$5,
139				$6
140			)
141			",
142			room_id.to_string(),
143			user_id.to_string(),
144			event_id.to_string(),
145			receipt_type as DbReceiptType,
146			thread.as_str(),
147			ts
148		)
149		.execute(self.connection().await?)
150		.await?;
151
152		Ok(())
153	}
154
155	/// Deletes all the read receipts given in the `nids` list.
156	#[tracing::instrument(skip(self))]
157	async fn delete_read_receipts(&mut self, nids: &[i32]) -> Result<(), DatabaseError> {
158		if sqlx::query!("DELETE FROM read_receipts WHERE nid = ANY($1::int[])", nids)
159			.execute(self.connection().await?)
160			.await?
161			.rows_affected() as usize
162			!= nids.len()
163		{
164			return Err(DatabaseError::SqlxError(sqlx::Error::RowNotFound));
165		}
166
167		Ok(())
168	}
169
170	/// Fetches all the new read receipts to sync.
171	///
172	/// NOTE: This will filter in only read receipts that the given user is
173	/// allowed to see.
174	#[tracing::instrument(skip(self))]
175	async fn fetch_sync_read_receipts(
176		&mut self,
177		user_id: &UserId,
178		from_stream_id: StreamId,
179		filter: Option<&RoomEventFilter>,
180	) -> Result<SyncRoomReceipts, DatabaseError> {
181		let types = filter.and_then(|filter| filter.types.clone());
182		let not_types = filter.map(|filter| filter.not_types.clone()).unwrap_or_default();
183		let should_have_receipts = !(types
184			.is_some_and(|types| !types.contains(&"m.receipt".to_owned()))
185			|| not_types.contains(&"m.receipt".to_owned()));
186
187		let rows = sqlx::query!(
188			r#"
189			SELECT
190				rooms.room_id,
191				read_receipts.user_id,
192				events.event_id,
193				read_receipts.stream_id,
194				read_receipts.receipt_type AS "receipt_type:DbReceiptType",
195				read_receipts.thread,
196				read_receipts.ts
197			FROM
198				read_receipts
199				JOIN room_membership_state ($2) ON read_receipts.room_nid = room_membership_state.room_nid
200				JOIN rooms ON read_receipts.room_nid = rooms.nid
201				JOIN events ON read_receipts.event_nid = events.nid
202			WHERE
203				-- Filter in rooms the user is joined in
204				room_membership_state.membership = 'join'
205				-- Filter in receipts that were stored after the given stream ID
206				AND (read_receipts.stream_id > $1
207					AND read_receipts.stream_id <= max_safe_seq ('read_receipts_streamids'))
208				-- Users are allowed to see all of their own read receipts, and only the public
209				-- ones of other users
210				AND (read_receipts.user_id = $2
211					OR read_receipts.receipt_type = 'm.read')
212				AND $3
213			"#,
214			from_stream_id,
215			user_id.to_string(),
216			should_have_receipts
217		)
218		.fetch_all(self.connection().await?)
219		.await?;
220
221		let mut max_stream_id = StreamId::MIN;
222		let mut receipts: SyncReceiptsMap = BTreeMap::new();
223
224		for row in rows {
225			max_stream_id = max_stream_id.max(row.stream_id);
226
227			let room_id = RoomId::parse(row.room_id).invalid_data("Invalid room ID in database")?;
228			let user_id = UserId::parse(row.user_id).invalid_data("Invalid user ID in database")?;
229			let event_id =
230				EventId::parse(row.event_id).invalid_data("Invalid event ID in database")?;
231			let receipt_type = row.receipt_type.into();
232			let receipt_thread = ReceiptThread::try_from(row.thread)
233				.invalid_data("Invalid receipt thread in database")?;
234
235			let ts = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::from(row.ts)).ok_or(
236				DatabaseError::InvalidData("Invalid read receipt timestamp in database".to_owned()),
237			)?;
238
239			let mut receipt = Receipt::new(ts);
240			receipt.thread = receipt_thread;
241
242			receipts
243				.entry(room_id)
244				.or_default()
245				.entry(event_id)
246				.or_default()
247				.entry(receipt_type)
248				.or_default()
249				.entry(user_id)
250				.or_insert(receipt);
251		}
252
253		Ok(SyncRoomReceipts { max_stream_id, receipts })
254	}
255}
256
257impl<T> ReadReceiptsQueries for T where T: Database {}