1use chrono::{DateTime, Utc};
8use serde::Serialize;
9use sqlx::SqlitePool;
10use uuid::Uuid;
11
12use crate::auth::uuid_from_bytes;
13use crate::error::Error;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum GroupRole {
19 Owner,
22 Member,
24}
25
26impl GroupRole {
27 #[must_use]
29 pub const fn as_str(self) -> &'static str {
30 match self {
31 Self::Owner => "owner",
32 Self::Member => "member",
33 }
34 }
35
36 pub fn parse(raw: &str) -> Result<Self, Error> {
43 match raw {
44 "owner" => Ok(Self::Owner),
45 "member" => Ok(Self::Member),
46 other => Err(Error::BadRequest(format!("unknown role `{other}`"))),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub enum InvitationStatus {
55 Pending,
57 Accepted,
59 Rejected,
61}
62
63impl InvitationStatus {
64 #[must_use]
66 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::Pending => "pending",
69 Self::Accepted => "accepted",
70 Self::Rejected => "rejected",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize)]
77pub struct GroupView {
78 pub group_id: Uuid,
80 pub name: String,
82 pub created_by: Option<Uuid>,
86 pub created_at: DateTime<Utc>,
88 pub updated_at: DateTime<Utc>,
90 pub my_role: GroupRole,
92}
93
94#[derive(Debug, Clone, Serialize)]
96pub struct GroupMemberView {
97 pub user_id: Uuid,
99 pub username: String,
101 pub legacy_name: String,
103 pub role: GroupRole,
105 pub created_at: DateTime<Utc>,
107}
108
109pub async fn lookup_role(
116 db: &SqlitePool,
117 group_id: Uuid,
118 user_id: Uuid,
119) -> Result<Option<GroupRole>, Error> {
120 let row: Option<(String,)> =
121 sqlx::query_as("SELECT role FROM group_memberships WHERE group_id = ?1 AND user_id = ?2")
122 .bind(group_id.as_bytes().to_vec())
123 .bind(user_id.as_bytes().to_vec())
124 .fetch_optional(db)
125 .await
126 .map_err(|err| {
127 tracing::error!("group role lookup failed: {err}");
128 Error::Database
129 })?;
130 row.map(|(role,)| GroupRole::parse(&role)).transpose()
131}
132
133pub async fn try_promote_member_to_owner(
142 db: &SqlitePool,
143 group_id: Uuid,
144 user_id: Uuid,
145) -> Result<bool, Error> {
146 let result = sqlx::query(
147 "UPDATE group_memberships SET role = 'owner' \
148 WHERE group_id = ?1 AND user_id = ?2 AND role = 'member'",
149 )
150 .bind(group_id.as_bytes().to_vec())
151 .bind(user_id.as_bytes().to_vec())
152 .execute(db)
153 .await
154 .map_err(|err| {
155 tracing::error!("promote to owner failed: {err}");
156 Error::Database
157 })?;
158 Ok(result.rows_affected() == 1)
159}
160
161pub async fn try_self_demote_owner(
171 db: &SqlitePool,
172 group_id: Uuid,
173 user_id: Uuid,
174) -> Result<bool, Error> {
175 let result = sqlx::query(
176 "UPDATE group_memberships SET role = 'member' \
177 WHERE group_id = ?1 AND user_id = ?2 AND role = 'owner' \
178 AND (SELECT COUNT(*) FROM group_memberships \
179 WHERE group_id = ?1 AND role = 'owner') > 1",
180 )
181 .bind(group_id.as_bytes().to_vec())
182 .bind(user_id.as_bytes().to_vec())
183 .execute(db)
184 .await
185 .map_err(|err| {
186 tracing::error!("self-demote owner failed: {err}");
187 Error::Database
188 })?;
189 Ok(result.rows_affected() == 1)
190}
191
192pub async fn try_remove_non_owner(
201 db: &SqlitePool,
202 group_id: Uuid,
203 user_id: Uuid,
204) -> Result<bool, Error> {
205 let result = sqlx::query(
206 "DELETE FROM group_memberships \
207 WHERE group_id = ?1 AND user_id = ?2 AND role = 'member'",
208 )
209 .bind(group_id.as_bytes().to_vec())
210 .bind(user_id.as_bytes().to_vec())
211 .execute(db)
212 .await
213 .map_err(|err| {
214 tracing::error!("remove non-owner member failed: {err}");
215 Error::Database
216 })?;
217 Ok(result.rows_affected() == 1)
218}
219
220pub async fn try_leave(db: &SqlitePool, group_id: Uuid, user_id: Uuid) -> Result<bool, Error> {
229 let result = sqlx::query(
230 "DELETE FROM group_memberships \
231 WHERE group_id = ?1 AND user_id = ?2 \
232 AND (role <> 'owner' \
233 OR (SELECT COUNT(*) FROM group_memberships \
234 WHERE group_id = ?1 AND role = 'owner') > 1)",
235 )
236 .bind(group_id.as_bytes().to_vec())
237 .bind(user_id.as_bytes().to_vec())
238 .execute(db)
239 .await
240 .map_err(|err| {
241 tracing::error!("leave group failed: {err}");
242 Error::Database
243 })?;
244 Ok(result.rows_affected() == 1)
245}
246
247pub async fn require_exists(db: &SqlitePool, group_id: Uuid) -> Result<(), Error> {
254 let row: Option<(i64,)> = sqlx::query_as("SELECT 1 FROM groups WHERE group_id = ?1")
255 .bind(group_id.as_bytes().to_vec())
256 .fetch_optional(db)
257 .await
258 .map_err(|err| {
259 tracing::error!("group existence check failed: {err}");
260 Error::Database
261 })?;
262 if row.is_none() {
263 return Err(Error::NotFound(format!("group {group_id}")));
264 }
265 Ok(())
266}
267
268pub async fn create_group(db: &SqlitePool, name: &str, creator: Uuid) -> Result<Uuid, Error> {
275 let group_id = Uuid::new_v4();
276 let now = Utc::now();
277 let mut tx = db.begin().await.map_err(|err| {
278 tracing::error!("begin tx for group create failed: {err}");
279 Error::Database
280 })?;
281 sqlx::query(
282 "INSERT INTO groups (group_id, name, created_by, created_at, updated_at) \
283 VALUES (?1, ?2, ?3, ?4, ?4)",
284 )
285 .bind(group_id.as_bytes().to_vec())
286 .bind(name)
287 .bind(creator.as_bytes().to_vec())
288 .bind(now)
289 .execute(&mut *tx)
290 .await
291 .map_err(|err| {
292 tracing::error!("insert groups row failed: {err}");
293 Error::Database
294 })?;
295 sqlx::query(
296 "INSERT INTO group_memberships (group_id, user_id, role, created_at) \
297 VALUES (?1, ?2, 'owner', ?3)",
298 )
299 .bind(group_id.as_bytes().to_vec())
300 .bind(creator.as_bytes().to_vec())
301 .bind(now)
302 .execute(&mut *tx)
303 .await
304 .map_err(|err| {
305 tracing::error!("insert owner membership failed: {err}");
306 Error::Database
307 })?;
308 tx.commit().await.map_err(|err| {
309 tracing::error!("commit group create failed: {err}");
310 Error::Database
311 })?;
312 Ok(group_id)
313}
314
315type MemberRow = (Vec<u8>, String, String, String, DateTime<Utc>);
318
319pub async fn list_members(db: &SqlitePool, group_id: Uuid) -> Result<Vec<GroupMemberView>, Error> {
326 let rows: Vec<MemberRow> = sqlx::query_as(
327 "SELECT users.user_id, users.username, users.legacy_name, \
328 group_memberships.role, group_memberships.created_at \
329 FROM group_memberships \
330 JOIN users ON users.user_id = group_memberships.user_id \
331 WHERE group_memberships.group_id = ?1 \
332 ORDER BY group_memberships.role DESC, users.username ASC",
333 )
334 .bind(group_id.as_bytes().to_vec())
335 .fetch_all(db)
336 .await
337 .map_err(|err| {
338 tracing::error!("list members failed: {err}");
339 Error::Database
340 })?;
341 let mut out = Vec::with_capacity(rows.len());
342 for (uid_bytes, username, legacy_name, role, created_at) in rows {
343 let user_id =
344 uuid_from_bytes(&uid_bytes).ok_or_else(|| Error::BadRequest("bad uuid".to_owned()))?;
345 out.push(GroupMemberView {
346 user_id,
347 username,
348 legacy_name,
349 role: GroupRole::parse(&role)?,
350 created_at,
351 });
352 }
353 Ok(out)
354}
355
356type GroupRow = (
361 Vec<u8>,
362 String,
363 Option<Vec<u8>>,
364 DateTime<Utc>,
365 DateTime<Utc>,
366 String,
367);
368
369pub async fn list_for_user(db: &SqlitePool, user_id: Uuid) -> Result<Vec<GroupView>, Error> {
375 let rows: Vec<GroupRow> = sqlx::query_as(
376 "SELECT groups.group_id, groups.name, groups.created_by, \
377 groups.created_at, groups.updated_at, group_memberships.role \
378 FROM groups \
379 JOIN group_memberships ON group_memberships.group_id = groups.group_id \
380 WHERE group_memberships.user_id = ?1 \
381 ORDER BY groups.name ASC",
382 )
383 .bind(user_id.as_bytes().to_vec())
384 .fetch_all(db)
385 .await
386 .map_err(|err| {
387 tracing::error!("list groups for user failed: {err}");
388 Error::Database
389 })?;
390 let mut out = Vec::with_capacity(rows.len());
391 for (gid_bytes, name, created_by_bytes, created_at, updated_at, role) in rows {
392 let group_id = uuid_from_bytes(&gid_bytes)
393 .ok_or_else(|| Error::BadRequest("bad group uuid".to_owned()))?;
394 let created_by = created_by_bytes
395 .as_deref()
396 .map(uuid_from_bytes)
397 .map(|opt| opt.ok_or_else(|| Error::BadRequest("bad creator uuid".to_owned())))
398 .transpose()?;
399 out.push(GroupView {
400 group_id,
401 name,
402 created_by,
403 created_at,
404 updated_at,
405 my_role: GroupRole::parse(&role)?,
406 });
407 }
408 Ok(out)
409}
410
411pub async fn get_for_user(
418 db: &SqlitePool,
419 group_id: Uuid,
420 user_id: Uuid,
421) -> Result<GroupView, Error> {
422 type GetGroupRow = (
423 String,
424 Option<Vec<u8>>,
425 DateTime<Utc>,
426 DateTime<Utc>,
427 String,
428 );
429 let row: Option<GetGroupRow> = sqlx::query_as(
430 "SELECT groups.name, groups.created_by, groups.created_at, groups.updated_at, \
431 group_memberships.role \
432 FROM groups \
433 JOIN group_memberships ON group_memberships.group_id = groups.group_id \
434 WHERE groups.group_id = ?1 AND group_memberships.user_id = ?2",
435 )
436 .bind(group_id.as_bytes().to_vec())
437 .bind(user_id.as_bytes().to_vec())
438 .fetch_optional(db)
439 .await
440 .map_err(|err| {
441 tracing::error!("get group for user failed: {err}");
442 Error::Database
443 })?;
444 let (name, created_by_bytes, created_at, updated_at, role) =
445 row.ok_or_else(|| Error::NotFound(format!("group {group_id}")))?;
446 let created_by = created_by_bytes
447 .as_deref()
448 .map(uuid_from_bytes)
449 .map(|opt| opt.ok_or_else(|| Error::BadRequest("bad creator uuid".to_owned())))
450 .transpose()?;
451 Ok(GroupView {
452 group_id,
453 name,
454 created_by,
455 created_at,
456 updated_at,
457 my_role: GroupRole::parse(&role)?,
458 })
459}
460
461pub async fn rename_group(db: &SqlitePool, group_id: Uuid, name: &str) -> Result<(), Error> {
467 let now = Utc::now();
468 sqlx::query("UPDATE groups SET name = ?1, updated_at = ?2 WHERE group_id = ?3")
469 .bind(name)
470 .bind(now)
471 .bind(group_id.as_bytes().to_vec())
472 .execute(db)
473 .await
474 .map_err(|err| {
475 tracing::error!("rename group failed: {err}");
476 Error::Database
477 })?;
478 Ok(())
479}
480
481pub async fn delete_group(db: &SqlitePool, group_id: Uuid) -> Result<(), Error> {
488 sqlx::query("DELETE FROM groups WHERE group_id = ?1")
489 .bind(group_id.as_bytes().to_vec())
490 .execute(db)
491 .await
492 .map_err(|err| {
493 tracing::error!("delete group failed: {err}");
494 Error::Database
495 })?;
496 Ok(())
497}