simplify list ownership & invitations
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user