simplify list ownership & invitations

This commit is contained in:
2026-08-01 15:46:38 -04:00
parent 65886903cd
commit 5da5fee942
3 changed files with 138 additions and 223 deletions
+70 -141
View File
@@ -46,22 +46,9 @@ pub struct SessionUser {
pub struct GroceryList {
pub id: String,
pub name: String,
pub owner_id: String,
pub revision: i64,
}
#[derive(Clone, Debug)]
pub struct ListAccess {
pub list: GroceryList,
pub role: String,
}
#[derive(Clone, Debug)]
pub struct ListSummary {
pub list: GroceryList,
pub role: String,
}
#[derive(Clone, Debug)]
pub struct Item {
pub id: String,
@@ -80,11 +67,6 @@ pub struct Category {
pub name: String,
}
#[derive(Clone, Debug)]
pub struct InvitationInfo {
pub list_name: String,
}
impl Database {
pub fn open(path: impl AsRef<Path>) -> DbResult<Self> {
let connection = Connection::open(path).map_err(sql_error)?;
@@ -240,27 +222,21 @@ impl Database {
.await
}
pub async fn list_summaries(&self, user_id: String) -> DbResult<Vec<ListSummary>> {
pub async fn list_summaries(&self) -> DbResult<Vec<GroceryList>> {
self.call(move |connection| {
let mut statement = connection
.prepare(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role
"SELECT l.id, l.name, l.revision
FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE m.user_id = ?1
ORDER BY l.created_at DESC",
)
.map_err(sql_error)?;
let rows = statement
.query_map(params![user_id], |row| {
Ok(ListSummary {
list: GroceryList {
id: row.get(0)?,
name: row.get(1)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
.query_map([], |row| {
Ok(GroceryList {
id: row.get(0)?,
name: row.get(1)?,
revision: row.get(2)?,
})
})
.map_err(sql_error)?;
@@ -270,27 +246,19 @@ impl Database {
.await
}
pub async fn create_list(&self, owner_id: String, name: String) -> DbResult<GroceryList> {
pub async fn create_list(&self, name: String) -> DbResult<GroceryList> {
self.call(move |connection| {
let list = GroceryList {
id: Uuid::new_v4().to_string(),
name,
owner_id: owner_id.clone(),
revision: 0,
};
let transaction = connection.transaction().map_err(sql_error)?;
transaction
.execute(
"INSERT INTO lists (id, name, owner_id, revision, created_at)
VALUES (?1, ?2, ?3, 0, ?4)",
params![list.id, list.name, owner_id, now()],
)
.map_err(sql_error)?;
transaction
.execute(
"INSERT INTO list_members (list_id, user_id, role)
VALUES (?1, ?2, 'owner')",
params![list.id, list.owner_id],
"INSERT INTO lists (id, name, revision, created_at)
VALUES (?1, ?2, 0, ?3)",
params![list.id, list.name, now()],
)
.map_err(sql_error)?;
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
@@ -314,28 +282,19 @@ impl Database {
.await
}
pub async fn list_access(
&self,
list_id: String,
user_id: String,
) -> DbResult<Option<ListAccess>> {
pub async fn list_access(&self, list_id: String) -> DbResult<Option<GroceryList>> {
self.call(move |connection| {
connection
.query_row(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role
"SELECT l.id, l.name, l.revision
FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE l.id = ?1 AND m.user_id = ?2",
params![list_id, user_id],
WHERE l.id = ?1",
params![list_id],
|row| {
Ok(ListAccess {
list: GroceryList {
id: row.get(0)?,
name: row.get(1)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
Ok(GroceryList {
id: row.get(0)?,
name: row.get(1)?,
revision: row.get(2)?,
})
},
)
@@ -547,19 +506,14 @@ impl Database {
.await
}
pub async fn create_invitation(
&self,
list_id: String,
created_by: String,
token: String,
) -> DbResult<i64> {
pub async fn create_invitation(&self, created_by: String, token: String) -> DbResult<i64> {
self.call(move |connection| {
let expires_at = now() + 60 * 60 * 24 * 7;
connection
.execute(
"INSERT INTO invitations (token_hash, list_id, created_by, expires_at)
VALUES (?1, ?2, ?3, ?4)",
params![hash_secret(&token), list_id, created_by, expires_at],
"INSERT INTO invitations (token_hash, created_by, expires_at)
VALUES (?1, ?2, ?3)",
params![hash_secret(&token), created_by, expires_at],
)
.map_err(sql_error)?;
Ok(expires_at)
@@ -567,47 +521,39 @@ impl Database {
.await
}
pub async fn invitation(&self, token: String) -> DbResult<Option<InvitationInfo>> {
pub async fn invitation(&self, token: String) -> DbResult<bool> {
self.call(move |connection| {
connection
let valid = connection
.query_row(
"SELECT l.name
FROM invitations i
JOIN lists l ON l.id = i.list_id
WHERE i.token_hash = ?1 AND i.expires_at > ?2",
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()],
|row| {
Ok(InvitationInfo {
list_name: row.get(0)?,
})
},
|_| Ok(()),
)
.optional()
.map_err(sql_error)
.map_err(sql_error)?
.is_some();
Ok(valid)
})
.await
}
pub async fn accept_invitation(&self, token: String, user_id: String) -> DbResult<String> {
pub async fn accept_invitation(&self, token: String) -> DbResult<()> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let invitation = transaction
let valid = transaction
.query_row(
"SELECT list_id FROM invitations
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()],
|row| row.get::<_, String>(0),
|_| Ok(()),
)
.optional()
.map_err(sql_error)?
.ok_or(DbError::NotFound)?;
transaction
.execute(
"INSERT OR IGNORE INTO list_members (list_id, user_id, role)
VALUES (?1, ?2, 'member')",
params![invitation, user_id],
)
.map_err(sql_error)?;
.is_some();
if !valid {
return Err(DbError::NotFound);
}
transaction
.execute(
"DELETE FROM invitations WHERE token_hash = ?1",
@@ -615,7 +561,7 @@ impl Database {
)
.map_err(sql_error)?;
transaction.commit().map_err(sql_error)?;
Ok(invitation)
Ok(())
})
.await
}
@@ -653,16 +599,9 @@ fn migrate(connection: &Connection) -> DbResult<()> {
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS list_members (
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
PRIMARY KEY (list_id, user_id)
);
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
@@ -673,7 +612,6 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
@@ -692,8 +630,7 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS list_members_user_idx ON list_members(user_id);",
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
)
.map_err(sql_error)?;
@@ -798,12 +735,8 @@ mod tests {
#[tokio::test]
async fn creates_a_list_and_item() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user("test@example.com".into(), "Test User".into(), "hash".into())
.await
.unwrap();
let list = database
.create_list(user.id.clone(), "Weekly shop".into())
.create_list("Weekly shop".into())
.await
.unwrap();
database
@@ -829,12 +762,8 @@ mod tests {
.create_user("owner@example.com".into(), "Owner".into(), "hash".into())
.await
.unwrap();
let member = database
.create_user("member@example.com".into(), "Member".into(), "hash".into())
.await
.unwrap();
let list = database
.create_list(owner.id.clone(), "Household".into())
.create_list("Household".into())
.await
.unwrap();
assert_eq!(database.categories(list.id.clone()).await.unwrap().len(), 6);
@@ -848,52 +777,60 @@ mod tests {
assert_eq!(session.user.id, owner.id);
assert_eq!(session.csrf_token, csrf_token);
// Any registered account can access every list.
assert_eq!(
database
.list_access(list.id.clone())
.await
.unwrap()
.unwrap()
.name,
"Household"
);
let invitation_token = "test-invitation".to_owned();
database
.create_invitation(list.id.clone(), owner.id, invitation_token.clone())
.create_invitation(owner.id.clone(), invitation_token.clone())
.await
.unwrap();
assert_eq!(
assert!(
database
.invitation(invitation_token.clone())
.await
.unwrap()
.unwrap()
.list_name,
"Household"
);
let accepted_list = database
.accept_invitation(invitation_token.clone(), member.id.clone())
database
.accept_invitation(invitation_token.clone())
.await
.unwrap();
assert_eq!(accepted_list, list.id);
assert!(
database
!database
.invitation(invitation_token)
.await
.unwrap()
.is_none()
);
// A list created later is also accessible to every account.
let future_list = database
.create_list("Future shop".into())
.await
.unwrap();
assert_eq!(
database
.list_access(list.id, member.id)
.list_access(future_list.id)
.await
.unwrap()
.unwrap()
.role,
"member"
.name,
"Future shop"
);
}
#[tokio::test]
async fn checked_state_is_set_not_toggled() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user("check@example.com".into(), "Checker".into(), "hash".into())
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
let list = database.create_list("List".into()).await.unwrap();
database
.add_item(
list.id.clone(),
@@ -923,15 +860,7 @@ mod tests {
#[tokio::test]
async fn checking_an_item_does_not_change_list_order() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user(
"order@example.com".into(),
"Order Tester".into(),
"hash".into(),
)
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
let list = database.create_list("List".into()).await.unwrap();
database
.add_item(
list.id.clone(),