Compare commits
3
Commits
65886903cd
...
5e57c61e4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e57c61e4b | ||
|
|
2db105bd6f | ||
|
|
5da5fee942 |
@@ -2,3 +2,4 @@
|
||||
/sustenance.db
|
||||
/sustenance.db*
|
||||
/.env
|
||||
/seed.json
|
||||
|
||||
Generated
+2
-98
@@ -132,12 +132,6 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -314,21 +308,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
@@ -445,17 +428,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -676,12 +648,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.7"
|
||||
@@ -781,12 +747,6 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
@@ -952,13 +912,13 @@ dependencies = [
|
||||
"rand 0.8.7",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1221,17 +1181,6 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -1265,51 +1214,6 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.126"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
|
||||
+1
-1
@@ -12,10 +12,10 @@ maud = "0.27"
|
||||
rand = "0.8"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -19,8 +19,25 @@ Open <http://127.0.0.1:3000>. The application creates `sustenance.db` in the wor
|
||||
| `PUBLIC_BASE_URL` | derived from `BIND_ADDRESS` | Base URL used in invitation links |
|
||||
| `COOKIE_SECURE` | `false` | Add the `Secure` attribute to session cookies |
|
||||
| `REGISTRATION_MODE` | `invite_only` | Use `open` for local development; otherwise registration requires a valid list invitation after the first account |
|
||||
| `SEED_CONFIG` | `seed.json` | Optional JSON file with a default user to create when the database is first initialized |
|
||||
| `RUST_LOG` | `sustenance=debug,tower_http=info` | Log filter |
|
||||
|
||||
### Seeding a default user
|
||||
|
||||
If a JSON config file exists at the path given by `SEED_CONFIG` (default `seed.json`),
|
||||
Sustenance creates the configured user on startup when the database has no users yet.
|
||||
The file is optional — if it is missing or invalid, seeding is silently skipped.
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"email": "you@example.com",
|
||||
"display_name": "You",
|
||||
"password": "a-strong-password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Current features
|
||||
|
||||
- Email/password accounts with Argon2 password hashes
|
||||
|
||||
@@ -8,7 +8,6 @@ use rand::{RngCore, rngs::OsRng};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
@@ -31,7 +30,7 @@ pub struct Database {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub id: i64,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
@@ -44,47 +43,29 @@ pub struct SessionUser {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GroceryList {
|
||||
pub id: String,
|
||||
pub id: i64,
|
||||
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,
|
||||
pub list_id: String,
|
||||
pub id: i64,
|
||||
pub list_id: i64,
|
||||
pub name: String,
|
||||
pub quantity: String,
|
||||
pub note: String,
|
||||
pub category_id: Option<String>,
|
||||
pub category_id: Option<i64>,
|
||||
pub checked: bool,
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Category {
|
||||
pub id: String,
|
||||
pub id: i64,
|
||||
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)?;
|
||||
@@ -124,20 +105,21 @@ impl Database {
|
||||
password_hash: String,
|
||||
) -> DbResult<User> {
|
||||
self.call(move |connection| {
|
||||
let user = User {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
email,
|
||||
display_name,
|
||||
};
|
||||
|
||||
let result = connection.execute(
|
||||
"INSERT INTO users (id, email, display_name, password_hash, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![user.id, user.email, user.display_name, password_hash, now()],
|
||||
"INSERT INTO users (email, display_name, password_hash, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![email, display_name, password_hash, now()],
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(user),
|
||||
Ok(_) => {
|
||||
let id = connection.last_insert_rowid();
|
||||
Ok(User {
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
})
|
||||
}
|
||||
Err(error) if error.to_string().contains("UNIQUE") => Err(DbError::Conflict),
|
||||
Err(error) => Err(sql_error(error)),
|
||||
}
|
||||
@@ -180,7 +162,7 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session(&self, user_id: String) -> DbResult<(String, String)> {
|
||||
pub async fn create_session(&self, user_id: i64) -> DbResult<(String, String)> {
|
||||
self.call(move |connection| {
|
||||
let session_token = new_secret();
|
||||
let csrf_token = new_secret();
|
||||
@@ -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 {
|
||||
.query_map([], |row| {
|
||||
Ok(GroceryList {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
owner_id: row.get(2)?,
|
||||
revision: row.get(3)?,
|
||||
},
|
||||
role: row.get(4)?,
|
||||
revision: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.map_err(sql_error)?;
|
||||
@@ -270,72 +246,49 @@ 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 (name, revision, created_at)
|
||||
VALUES (?1, 0, ?2)",
|
||||
params![name, now()],
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
let list_id = transaction.last_insert_rowid();
|
||||
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO categories (id, list_id, name, position, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
Uuid::new_v4().to_string(),
|
||||
list.id,
|
||||
category_name,
|
||||
position as i64,
|
||||
now()
|
||||
],
|
||||
"INSERT INTO categories (list_id, name, position, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![list_id, category_name, position as i64, now()],
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
}
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(list)
|
||||
Ok(GroceryList {
|
||||
id: list_id,
|
||||
name,
|
||||
revision: 0,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_access(
|
||||
&self,
|
||||
list_id: String,
|
||||
user_id: String,
|
||||
) -> DbResult<Option<ListAccess>> {
|
||||
pub async fn list_access(&self, list_id: i64) -> 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 {
|
||||
Ok(GroceryList {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
owner_id: row.get(2)?,
|
||||
revision: row.get(3)?,
|
||||
},
|
||||
role: row.get(4)?,
|
||||
revision: row.get(2)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -345,7 +298,7 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn items(&self, list_id: String) -> DbResult<Vec<Item>> {
|
||||
pub async fn items(&self, list_id: i64) -> DbResult<Vec<Item>> {
|
||||
self.call(move |connection| {
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
@@ -374,7 +327,7 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn categories(&self, list_id: String) -> DbResult<Vec<Category>> {
|
||||
pub async fn categories(&self, list_id: i64) -> DbResult<Vec<Category>> {
|
||||
self.call(move |connection| {
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
@@ -397,7 +350,7 @@ impl Database {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_category(&self, list_id: String, name: String) -> DbResult<i64> {
|
||||
pub async fn create_category(&self, list_id: i64, name: String) -> DbResult<i64> {
|
||||
self.call(move |connection| {
|
||||
let transaction = connection.transaction().map_err(sql_error)?;
|
||||
let position: i64 = transaction
|
||||
@@ -409,9 +362,9 @@ impl Database {
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
let result = transaction.execute(
|
||||
"INSERT INTO categories (id, list_id, name, position, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![Uuid::new_v4().to_string(), list_id, name, position, now()],
|
||||
"INSERT INTO categories (list_id, name, position, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![list_id, name, position, now()],
|
||||
);
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
@@ -420,7 +373,7 @@ impl Database {
|
||||
}
|
||||
Err(error) => return Err(sql_error(error)),
|
||||
}
|
||||
let revision = bump_revision(&transaction, &list_id)?;
|
||||
let revision = bump_revision(&transaction, list_id)?;
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(revision)
|
||||
})
|
||||
@@ -429,16 +382,15 @@ impl Database {
|
||||
|
||||
pub async fn add_item(
|
||||
&self,
|
||||
list_id: String,
|
||||
list_id: i64,
|
||||
name: String,
|
||||
quantity: String,
|
||||
note: String,
|
||||
category_id: Option<String>,
|
||||
category_id: Option<i64>,
|
||||
) -> DbResult<i64> {
|
||||
self.call(move |connection| {
|
||||
let transaction = connection.transaction().map_err(sql_error)?;
|
||||
let category_id = category_id.filter(|category_id| !category_id.is_empty());
|
||||
ensure_category(&transaction, &list_id, category_id.as_deref())?;
|
||||
ensure_category(&transaction, list_id, category_id)?;
|
||||
let position: i64 = transaction
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM items WHERE list_id = ?1",
|
||||
@@ -449,21 +401,12 @@ impl Database {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO items
|
||||
(id, list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 1, ?7, ?8, ?8)",
|
||||
params![
|
||||
Uuid::new_v4().to_string(),
|
||||
list_id,
|
||||
name,
|
||||
quantity,
|
||||
note,
|
||||
category_id,
|
||||
position,
|
||||
now()
|
||||
],
|
||||
(list_id, name, quantity, note, category_id, checked, version, position, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6, ?7, ?7)",
|
||||
params![list_id, name, quantity, note, category_id, position, now()],
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
let revision = bump_revision(&transaction, &list_id)?;
|
||||
let revision = bump_revision(&transaction, list_id)?;
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(revision)
|
||||
})
|
||||
@@ -472,8 +415,8 @@ impl Database {
|
||||
|
||||
pub async fn set_item_checked(
|
||||
&self,
|
||||
list_id: String,
|
||||
item_id: String,
|
||||
list_id: i64,
|
||||
item_id: i64,
|
||||
checked: bool,
|
||||
) -> DbResult<i64> {
|
||||
self.call(move |connection| {
|
||||
@@ -489,7 +432,7 @@ impl Database {
|
||||
if changed == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
let revision = bump_revision(&transaction, &list_id)?;
|
||||
let revision = bump_revision(&transaction, list_id)?;
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(revision)
|
||||
})
|
||||
@@ -498,17 +441,16 @@ impl Database {
|
||||
|
||||
pub async fn update_item(
|
||||
&self,
|
||||
list_id: String,
|
||||
item_id: String,
|
||||
list_id: i64,
|
||||
item_id: i64,
|
||||
name: String,
|
||||
quantity: String,
|
||||
note: String,
|
||||
category_id: Option<String>,
|
||||
category_id: Option<i64>,
|
||||
) -> DbResult<i64> {
|
||||
self.call(move |connection| {
|
||||
let transaction = connection.transaction().map_err(sql_error)?;
|
||||
let category_id = category_id.filter(|category_id| !category_id.is_empty());
|
||||
ensure_category(&transaction, &list_id, category_id.as_deref())?;
|
||||
ensure_category(&transaction, list_id, category_id)?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE items
|
||||
@@ -521,14 +463,14 @@ impl Database {
|
||||
if changed == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
let revision = bump_revision(&transaction, &list_id)?;
|
||||
let revision = bump_revision(&transaction, list_id)?;
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(revision)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_item(&self, list_id: String, item_id: String) -> DbResult<i64> {
|
||||
pub async fn delete_item(&self, list_id: i64, item_id: i64) -> DbResult<i64> {
|
||||
self.call(move |connection| {
|
||||
let transaction = connection.transaction().map_err(sql_error)?;
|
||||
let changed = transaction
|
||||
@@ -540,26 +482,21 @@ impl Database {
|
||||
if changed == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
let revision = bump_revision(&transaction, &list_id)?;
|
||||
let revision = bump_revision(&transaction, list_id)?;
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(revision)
|
||||
})
|
||||
.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: i64, 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 +504,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 +544,7 @@ impl Database {
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
transaction.commit().map_err(sql_error)?;
|
||||
Ok(invitation)
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -638,7 +567,7 @@ fn migrate(connection: &Connection) -> DbResult<()> {
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
@@ -646,26 +575,19 @@ fn migrate(connection: &Connection) -> DbResult<()> {
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL COLLATE NOCASE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
@@ -673,17 +595,16 @@ 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,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
category_id TEXT REFERENCES categories(id) ON DELETE SET NULL,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -692,39 +613,17 @@ 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)?;
|
||||
|
||||
if !has_column(connection, "items", "category_id")? {
|
||||
connection
|
||||
.execute(
|
||||
"ALTER TABLE items ADD COLUMN category_id TEXT REFERENCES categories(id) ON DELETE SET NULL",
|
||||
[],
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_column(connection: &Connection, table: &str, wanted: &str) -> DbResult<bool> {
|
||||
let mut statement = connection
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.map_err(sql_error)?;
|
||||
let mut rows = statement.query([]).map_err(sql_error)?;
|
||||
while let Some(row) = rows.next().map_err(sql_error)? {
|
||||
if row.get::<_, String>(1).map_err(sql_error)? == wanted {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn ensure_category(
|
||||
transaction: &rusqlite::Transaction<'_>,
|
||||
list_id: &str,
|
||||
category_id: Option<&str>,
|
||||
list_id: i64,
|
||||
category_id: Option<i64>,
|
||||
) -> DbResult<()> {
|
||||
let Some(category_id) = category_id else {
|
||||
return Ok(());
|
||||
@@ -752,7 +651,7 @@ const DEFAULT_CATEGORIES: &[&str] = &[
|
||||
"Household",
|
||||
];
|
||||
|
||||
fn bump_revision(transaction: &rusqlite::Transaction<'_>, list_id: &str) -> DbResult<i64> {
|
||||
fn bump_revision(transaction: &rusqlite::Transaction<'_>, list_id: i64) -> DbResult<i64> {
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE lists SET revision = revision + 1 WHERE id = ?1",
|
||||
@@ -798,12 +697,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 +724,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 +739,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 +822,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(),
|
||||
|
||||
+21
-20
@@ -5,19 +5,19 @@ use tokio::sync::{Mutex, broadcast};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PresenceUser {
|
||||
pub user_id: String,
|
||||
pub user_id: i64,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HubEvent {
|
||||
ListChanged { list_id: String, revision: i64 },
|
||||
PresenceChanged { list_id: String },
|
||||
ListChanged { list_id: i64, revision: i64 },
|
||||
PresenceChanged { list_id: i64 },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConnectionInfo {
|
||||
user_id: String,
|
||||
user_id: i64,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
@@ -34,18 +34,18 @@ pub struct Subscription {
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Hub {
|
||||
rooms: Arc<Mutex<HashMap<String, Room>>>,
|
||||
rooms: Arc<Mutex<HashMap<i64, Room>>>,
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
pub async fn join(
|
||||
&self,
|
||||
list_id: String,
|
||||
user_id: String,
|
||||
list_id: i64,
|
||||
user_id: i64,
|
||||
display_name: String,
|
||||
) -> Subscription {
|
||||
let mut rooms = self.rooms.lock().await;
|
||||
let room = rooms.entry(list_id.clone()).or_insert_with(|| {
|
||||
let room = rooms.entry(list_id).or_insert_with(|| {
|
||||
let (sender, _) = broadcast::channel(64);
|
||||
Room {
|
||||
sender,
|
||||
@@ -79,10 +79,10 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn leave(&self, list_id: &str, connection_id: &str) {
|
||||
pub async fn leave(&self, list_id: i64, connection_id: &str) {
|
||||
let mut rooms = self.rooms.lock().await;
|
||||
let mut remove_room = false;
|
||||
if let Some(room) = rooms.get_mut(list_id) {
|
||||
if let Some(room) = rooms.get_mut(&list_id) {
|
||||
let removed = room.connections.remove(connection_id);
|
||||
if let Some(removed) = removed {
|
||||
let still_present = room
|
||||
@@ -90,19 +90,17 @@ impl Hub {
|
||||
.values()
|
||||
.any(|connection| connection.user_id == removed.user_id);
|
||||
if !still_present {
|
||||
let _ = room.sender.send(HubEvent::PresenceChanged {
|
||||
list_id: list_id.to_owned(),
|
||||
});
|
||||
let _ = room.sender.send(HubEvent::PresenceChanged { list_id });
|
||||
}
|
||||
}
|
||||
remove_room = room.connections.is_empty();
|
||||
}
|
||||
if remove_room {
|
||||
rooms.remove(list_id);
|
||||
rooms.remove(&list_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn publish_list_changed(&self, list_id: String, revision: i64) {
|
||||
pub async fn publish_list_changed(&self, list_id: i64, revision: i64) {
|
||||
let rooms = self.rooms.lock().await;
|
||||
if let Some(room) = rooms.get(&list_id) {
|
||||
let _ = room
|
||||
@@ -111,19 +109,22 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn presence(&self, list_id: &str) -> Vec<PresenceUser> {
|
||||
pub async fn presence(&self, list_id: i64) -> Vec<PresenceUser> {
|
||||
let rooms = self.rooms.lock().await;
|
||||
rooms.get(list_id).map(current_presence).unwrap_or_default()
|
||||
rooms
|
||||
.get(&list_id)
|
||||
.map(current_presence)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn current_presence(room: &Room) -> Vec<PresenceUser> {
|
||||
let mut users = HashMap::<String, PresenceUser>::new();
|
||||
let mut users = HashMap::<i64, PresenceUser>::new();
|
||||
for connection in room.connections.values() {
|
||||
users
|
||||
.entry(connection.user_id.clone())
|
||||
.entry(connection.user_id)
|
||||
.or_insert_with(|| PresenceUser {
|
||||
user_id: connection.user_id.clone(),
|
||||
user_id: connection.user_id,
|
||||
display_name: connection.display_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
+82
-89
@@ -1,9 +1,10 @@
|
||||
mod db;
|
||||
mod hub;
|
||||
mod seed;
|
||||
mod views;
|
||||
|
||||
use std::time::Duration;
|
||||
use std::{env, future::Future, sync::Arc};
|
||||
use std::{env, future::Future, path::Path as FilePath, sync::Arc};
|
||||
|
||||
use argon2::{
|
||||
Argon2,
|
||||
@@ -255,6 +256,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
registration_mode,
|
||||
};
|
||||
|
||||
let seed_path = env::var("SEED_CONFIG").unwrap_or_else(|_| "seed.json".into());
|
||||
seed::seed_if_needed(&state.db, FilePath::new(&seed_path)).await;
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/login", get(login_page).post(login))
|
||||
@@ -267,7 +271,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/lists/{list_id}/items/{item_id}/edit", post(edit_item))
|
||||
.route("/lists/{list_id}/items/{item_id}/delete", post(delete_item))
|
||||
.route("/lists/{list_id}/categories", post(create_category))
|
||||
.route("/lists/{list_id}/invitations", post(create_invitation))
|
||||
.route("/invitations", post(create_invitation))
|
||||
.route("/lists/{list_id}/stream", get(list_stream))
|
||||
.route("/invite/{token}", get(invitation_page))
|
||||
.route("/invite/{token}/accept", post(accept_invitation))
|
||||
@@ -349,12 +353,6 @@ async fn register(
|
||||
form.invite.as_deref(),
|
||||
)));
|
||||
}
|
||||
if form.password.len() < 8 {
|
||||
return Ok(html_response(views::register_page(
|
||||
Some("Use a password with at least 8 characters."),
|
||||
form.invite.as_deref(),
|
||||
)));
|
||||
}
|
||||
|
||||
let password = form.password;
|
||||
let password_hash = tokio::task::spawn_blocking(move || hash_password(&password))
|
||||
@@ -435,7 +433,7 @@ async fn lists_page(
|
||||
) -> Result<Response, AppError> {
|
||||
let lists = state
|
||||
.db
|
||||
.list_summaries(user.session.user.id.clone())
|
||||
.list_summaries()
|
||||
.await?;
|
||||
Ok(html_response(views::lists_page(
|
||||
&user.session.user,
|
||||
@@ -456,19 +454,19 @@ async fn create_list(
|
||||
"List names must be between 1 and 80 characters.".into(),
|
||||
));
|
||||
}
|
||||
let list = state.db.create_list(user.session.user.id, name).await?;
|
||||
let list = state.db.create_list(name).await?;
|
||||
Ok(Redirect::to(&format!("/lists/{}", list.id)).into_response())
|
||||
}
|
||||
|
||||
async fn list_page(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(list_id): Path<String>,
|
||||
Path(list_id): Path<i64>,
|
||||
) -> Result<Response, AppError> {
|
||||
let access = require_access(&state, &user, &list_id).await?;
|
||||
let items = state.db.items(list_id.clone()).await?;
|
||||
let categories = state.db.categories(list_id.clone()).await?;
|
||||
let presence = state.hub.presence(&list_id).await;
|
||||
let access = require_access(&state, list_id).await?;
|
||||
let items = state.db.items(list_id).await?;
|
||||
let categories = state.db.categories(list_id).await?;
|
||||
let presence = state.hub.presence(list_id).await;
|
||||
Ok(html_response(views::list_page(
|
||||
&user.session.user,
|
||||
&access,
|
||||
@@ -482,15 +480,15 @@ async fn list_page(
|
||||
async fn add_item(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(list_id): Path<String>,
|
||||
Path(list_id): Path<i64>,
|
||||
LoggedForm(form): LoggedForm<ItemForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
require_access(&state, &user, &list_id).await?;
|
||||
require_access(&state, list_id).await?;
|
||||
let name = form.name.trim().to_owned();
|
||||
let quantity = form.quantity.trim().to_owned();
|
||||
let note = form.note.trim().to_owned();
|
||||
let category_id = normalize_category_id(form.category_id);
|
||||
let category_id = parse_category_id(form.category_id);
|
||||
if name.is_empty() || name.chars().count() > 120 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Item names must be between 1 and 120 characters.".into(),
|
||||
@@ -498,23 +496,23 @@ async fn add_item(
|
||||
}
|
||||
let revision = state
|
||||
.db
|
||||
.add_item(list_id.clone(), name, quantity, note, category_id)
|
||||
.add_item(list_id, name, quantity, note, category_id)
|
||||
.await?;
|
||||
state
|
||||
.hub
|
||||
.publish_list_changed(list_id.clone(), revision)
|
||||
.publish_list_changed(list_id, revision)
|
||||
.await;
|
||||
list_fragment_response(&state, &user, &list_id).await
|
||||
list_fragment_response(&state, &user, list_id).await
|
||||
}
|
||||
|
||||
async fn check_item(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path((list_id, item_id)): Path<(String, String)>,
|
||||
Path((list_id, item_id)): Path<(i64, i64)>,
|
||||
LoggedForm(form): LoggedForm<CheckForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
require_access(&state, &user, &list_id).await?;
|
||||
require_access(&state, list_id).await?;
|
||||
let checked = match form.checked.as_str() {
|
||||
"1" | "true" => true,
|
||||
"0" | "false" => false,
|
||||
@@ -522,23 +520,23 @@ async fn check_item(
|
||||
};
|
||||
let revision = state
|
||||
.db
|
||||
.set_item_checked(list_id.clone(), item_id, checked)
|
||||
.set_item_checked(list_id, item_id, checked)
|
||||
.await?;
|
||||
state
|
||||
.hub
|
||||
.publish_list_changed(list_id.clone(), revision)
|
||||
.publish_list_changed(list_id, revision)
|
||||
.await;
|
||||
list_fragment_response(&state, &user, &list_id).await
|
||||
list_fragment_response(&state, &user, list_id).await
|
||||
}
|
||||
|
||||
async fn edit_item(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path((list_id, item_id)): Path<(String, String)>,
|
||||
Path((list_id, item_id)): Path<(i64, i64)>,
|
||||
LoggedForm(form): LoggedForm<ItemForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
require_access(&state, &user, &list_id).await?;
|
||||
require_access(&state, list_id).await?;
|
||||
let name = form.name.trim().to_owned();
|
||||
if name.is_empty() || name.chars().count() > 120 {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -548,59 +546,59 @@ async fn edit_item(
|
||||
let revision = state
|
||||
.db
|
||||
.update_item(
|
||||
list_id.clone(),
|
||||
list_id,
|
||||
item_id,
|
||||
name,
|
||||
form.quantity.trim().to_owned(),
|
||||
form.note.trim().to_owned(),
|
||||
normalize_category_id(form.category_id),
|
||||
parse_category_id(form.category_id),
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.hub
|
||||
.publish_list_changed(list_id.clone(), revision)
|
||||
.publish_list_changed(list_id, revision)
|
||||
.await;
|
||||
list_fragment_response(&state, &user, &list_id).await
|
||||
list_fragment_response(&state, &user, list_id).await
|
||||
}
|
||||
|
||||
async fn delete_item(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path((list_id, item_id)): Path<(String, String)>,
|
||||
Path((list_id, item_id)): Path<(i64, i64)>,
|
||||
LoggedForm(form): LoggedForm<CsrfForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
require_access(&state, &user, &list_id).await?;
|
||||
let revision = state.db.delete_item(list_id.clone(), item_id).await?;
|
||||
require_access(&state, list_id).await?;
|
||||
let revision = state.db.delete_item(list_id, item_id).await?;
|
||||
state
|
||||
.hub
|
||||
.publish_list_changed(list_id.clone(), revision)
|
||||
.publish_list_changed(list_id, revision)
|
||||
.await;
|
||||
list_fragment_response(&state, &user, &list_id).await
|
||||
list_fragment_response(&state, &user, list_id).await
|
||||
}
|
||||
|
||||
async fn create_category(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(list_id): Path<String>,
|
||||
Path(list_id): Path<i64>,
|
||||
LoggedForm(form): LoggedForm<CategoryForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
require_access(&state, &user, &list_id).await?;
|
||||
require_access(&state, list_id).await?;
|
||||
let name = form.name.trim().to_owned();
|
||||
if name.is_empty() || name.chars().count() > 60 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Category names must be between 1 and 60 characters.".into(),
|
||||
));
|
||||
}
|
||||
let revision = state.db.create_category(list_id.clone(), name).await?;
|
||||
let revision = state.db.create_category(list_id, name).await?;
|
||||
state
|
||||
.hub
|
||||
.publish_list_changed(list_id.clone(), revision)
|
||||
.publish_list_changed(list_id, revision)
|
||||
.await;
|
||||
|
||||
let access = require_access(&state, &user, &list_id).await?;
|
||||
let items = state.db.items(list_id.clone()).await?;
|
||||
let access = require_access(&state, list_id).await?;
|
||||
let items = state.db.items(list_id).await?;
|
||||
let categories = state.db.categories(list_id).await?;
|
||||
Ok(html_response(views::category_created(
|
||||
&access,
|
||||
@@ -613,20 +611,13 @@ async fn create_category(
|
||||
async fn create_invitation(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(list_id): Path<String>,
|
||||
LoggedForm(form): LoggedForm<CsrfForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
let access = require_access(&state, &user, &list_id).await?;
|
||||
if access.role != "owner" {
|
||||
return Err(AppError::BadRequest(
|
||||
"Only the list owner can create invitations.".into(),
|
||||
));
|
||||
}
|
||||
let token = db::new_secret();
|
||||
state
|
||||
.db
|
||||
.create_invitation(list_id, user.session.user.id, token.clone())
|
||||
.create_invitation(user.session.user.id, token.clone())
|
||||
.await?;
|
||||
let url = format!(
|
||||
"{}/invite/{token}",
|
||||
@@ -643,11 +634,12 @@ async fn invitation_page(
|
||||
let info = state
|
||||
.db
|
||||
.invitation(token.clone())
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
.await?;
|
||||
if !info {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let user = optional_user(&state, &headers).await?;
|
||||
Ok(html_response(views::invite_page(
|
||||
&info,
|
||||
user.as_ref().map(|current| ¤t.session.user),
|
||||
&token,
|
||||
None,
|
||||
@@ -663,20 +655,20 @@ async fn accept_invitation(
|
||||
LoggedForm(form): LoggedForm<CsrfForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
verify_csrf(&user, &form.csrf)?;
|
||||
let list_id = state
|
||||
state
|
||||
.db
|
||||
.accept_invitation(token, user.session.user.id)
|
||||
.accept_invitation(token)
|
||||
.await?;
|
||||
Ok(Redirect::to(&format!("/lists/{list_id}")).into_response())
|
||||
Ok(Redirect::to("/lists").into_response())
|
||||
}
|
||||
|
||||
async fn list_stream(
|
||||
State(state): State<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(list_id): Path<String>,
|
||||
Path(list_id): Path<i64>,
|
||||
websocket: WebSocketUpgrade,
|
||||
) -> Result<Response, AppError> {
|
||||
require_access(&state, &user, &list_id).await?;
|
||||
require_access(&state, list_id).await?;
|
||||
let state_for_socket = state.clone();
|
||||
let user_for_socket = user.clone();
|
||||
Ok(websocket
|
||||
@@ -684,12 +676,12 @@ async fn list_stream(
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, socket: WebSocket) {
|
||||
async fn handle_socket(state: AppState, user: CurrentUser, list_id: i64, socket: WebSocket) {
|
||||
let subscription = state
|
||||
.hub
|
||||
.join(
|
||||
list_id.clone(),
|
||||
user.session.user.id.clone(),
|
||||
list_id,
|
||||
user.session.user.id,
|
||||
user.session.user.display_name.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -698,16 +690,16 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
|
||||
let mut heartbeat = tokio::time::interval(Duration::from_secs(30));
|
||||
heartbeat.tick().await;
|
||||
|
||||
match websocket_snapshot(&state, &user, &list_id, &subscription.presence).await {
|
||||
match websocket_snapshot(&state, &user, list_id, &subscription.presence).await {
|
||||
Ok(snapshot) => {
|
||||
if sender.send(Message::Text(snapshot.into())).await.is_err() {
|
||||
state.hub.leave(&list_id, &connection_id).await;
|
||||
state.hub.leave(list_id, &connection_id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
error!(%error, "could not render websocket snapshot");
|
||||
state.hub.leave(&list_id, &connection_id).await;
|
||||
state.hub.leave(list_id, &connection_id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -719,7 +711,7 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
|
||||
match event {
|
||||
Ok(HubEvent::ListChanged { list_id: event_list_id, revision }) if event_list_id == list_id => {
|
||||
tracing::debug!(%list_id, revision, "list changed on websocket");
|
||||
match websocket_list_update(&state, &user, &list_id).await {
|
||||
match websocket_list_update(&state, &user, list_id).await {
|
||||
Ok(update) => {
|
||||
if sender.send(Message::Text(update.into())).await.is_err() {
|
||||
break;
|
||||
@@ -732,14 +724,14 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
|
||||
}
|
||||
}
|
||||
Ok(HubEvent::PresenceChanged { list_id: event_list_id }) if event_list_id == list_id => {
|
||||
let presence = state.hub.presence(&list_id).await;
|
||||
let presence = state.hub.presence(list_id).await;
|
||||
let update = views::presence_panel(&presence, true).into_string();
|
||||
if sender.send(Message::Text(update.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||
match websocket_snapshot(&state, &user, &list_id, &state.hub.presence(&list_id).await).await {
|
||||
match websocket_snapshot(&state, &user, list_id, &state.hub.presence(list_id).await).await {
|
||||
Ok(snapshot) => {
|
||||
if sender.send(Message::Text(snapshot.into())).await.is_err() {
|
||||
break;
|
||||
@@ -775,18 +767,18 @@ async fn handle_socket(state: AppState, user: CurrentUser, list_id: String, sock
|
||||
}
|
||||
}
|
||||
|
||||
state.hub.leave(&list_id, &connection_id).await;
|
||||
state.hub.leave(list_id, &connection_id).await;
|
||||
}
|
||||
|
||||
async fn websocket_snapshot(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
list_id: &str,
|
||||
list_id: i64,
|
||||
presence: &[hub::PresenceUser],
|
||||
) -> Result<String, AppError> {
|
||||
let access = require_access(state, user, list_id).await?;
|
||||
let items = state.db.items(list_id.to_owned()).await?;
|
||||
let categories = state.db.categories(list_id.to_owned()).await?;
|
||||
let access = require_access(state, list_id).await?;
|
||||
let items = state.db.items(list_id).await?;
|
||||
let categories = state.db.categories(list_id).await?;
|
||||
Ok(
|
||||
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
|
||||
.into_string()
|
||||
@@ -797,11 +789,11 @@ async fn websocket_snapshot(
|
||||
async fn websocket_list_update(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
list_id: &str,
|
||||
list_id: i64,
|
||||
) -> Result<String, AppError> {
|
||||
let access = require_access(state, user, list_id).await?;
|
||||
let items = state.db.items(list_id.to_owned()).await?;
|
||||
let categories = state.db.categories(list_id.to_owned()).await?;
|
||||
let access = require_access(state, list_id).await?;
|
||||
let items = state.db.items(list_id).await?;
|
||||
let categories = state.db.categories(list_id).await?;
|
||||
Ok(
|
||||
views::live_list_fragments(&access, &items, &categories, &user.session.csrf_token)
|
||||
.into_string(),
|
||||
@@ -811,13 +803,13 @@ async fn websocket_list_update(
|
||||
async fn list_fragment_response(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
list_id: &str,
|
||||
list_id: i64,
|
||||
) -> Result<Response, AppError> {
|
||||
let access = require_access(state, user, list_id).await?;
|
||||
let items = state.db.items(list_id.to_owned()).await?;
|
||||
let categories = state.db.categories(list_id.to_owned()).await?;
|
||||
let access = require_access(state, list_id).await?;
|
||||
let items = state.db.items(list_id).await?;
|
||||
let categories = state.db.categories(list_id).await?;
|
||||
Ok(html_response(views::list_items_fragment(
|
||||
&access.list,
|
||||
&access,
|
||||
&items,
|
||||
&categories,
|
||||
&user.session.csrf_token,
|
||||
@@ -827,12 +819,11 @@ async fn list_fragment_response(
|
||||
|
||||
async fn require_access(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
list_id: &str,
|
||||
) -> Result<db::ListAccess, AppError> {
|
||||
list_id: i64,
|
||||
) -> Result<db::GroceryList, AppError> {
|
||||
state
|
||||
.db
|
||||
.list_access(list_id.to_owned(), user.session.user.id.clone())
|
||||
.list_access(list_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)
|
||||
}
|
||||
@@ -863,8 +854,10 @@ fn verify_csrf(user: &CurrentUser, token: &str) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_category_id(category_id: Option<String>) -> Option<String> {
|
||||
category_id.filter(|category_id| !category_id.trim().is_empty())
|
||||
fn parse_category_id(category_id: Option<String>) -> Option<i64> {
|
||||
category_id
|
||||
.filter(|category_id| !category_id.trim().is_empty())
|
||||
.and_then(|category_id| category_id.trim().parse().ok())
|
||||
}
|
||||
|
||||
async fn can_register(state: &AppState, invite: Option<&str>) -> Result<bool, AppError> {
|
||||
@@ -877,7 +870,7 @@ async fn can_register(state: &AppState, invite: Option<&str>) -> Result<bool, Ap
|
||||
let Some(invite) = invite.filter(|invite| !invite.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(state.db.invitation(invite.to_owned()).await?.is_some())
|
||||
Ok(state.db.invitation(invite.to_owned()).await?)
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, String> {
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SeedConfig {
|
||||
#[serde(default)]
|
||||
pub user: Option<SeedUser>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SeedUser {
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Reads the seed config file and creates the configured default user if the
|
||||
/// database has no users yet. The file is optional; if it does not exist (or
|
||||
/// cannot be parsed) seeding is skipped.
|
||||
pub async fn seed_if_needed(db: &Database, path: &Path) {
|
||||
let Ok(contents) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let config: SeedConfig = match serde_json::from_str(&contents) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
warn!(path = %path.display(), %error, "could not parse seed config; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(user) = config.user else {
|
||||
return;
|
||||
};
|
||||
if user.email.trim().is_empty() {
|
||||
warn!("seed user requires a non-empty email; skipping");
|
||||
return;
|
||||
}
|
||||
if db.has_users().await.unwrap_or(true) {
|
||||
info!("database already has users; skipping seed");
|
||||
return;
|
||||
}
|
||||
let password_hash = match crate::hash_password(&user.password) {
|
||||
Ok(hash) => hash,
|
||||
Err(error) => {
|
||||
warn!(%error, "could not hash seed password; skipping");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match db
|
||||
.create_user(
|
||||
user.email.trim().to_lowercase(),
|
||||
user.display_name.trim().to_owned(),
|
||||
password_hash,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => info!(id = user.id, email = %user.email, "seeded default user"),
|
||||
Err(error) => warn!(%error, "could not seed default user"),
|
||||
}
|
||||
}
|
||||
+66
-73
@@ -1,7 +1,7 @@
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
|
||||
use crate::{
|
||||
db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User},
|
||||
db::{Category, GroceryList, Item, User},
|
||||
hub::PresenceUser,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
||||
div class="auth-card" {
|
||||
p class="eyebrow" { "SUSTENANCE" }
|
||||
h1 { "Welcome back" }
|
||||
p class="lede" { "Keep the household running, one item at a time." }
|
||||
p class="lede" { "Keep the shopping list in sync." }
|
||||
@if let Some(error) = error {
|
||||
div class="alert alert-error" role="alert" { (error) }
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup {
|
||||
label for="email" { "Email" }
|
||||
input id="email" name="email" type="email" autocomplete="email" required;
|
||||
label for="password" { "Password" }
|
||||
input id="password" name="password" type="password" autocomplete="new-password" minlength="8" required;
|
||||
input id="password" name="password" type="password" autocomplete="new-password" required;
|
||||
button class="button button-primary" type="submit" { "Create account" }
|
||||
}
|
||||
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
|
||||
@@ -69,23 +69,23 @@ pub fn registration_closed_page() -> Markup {
|
||||
None,
|
||||
html! {
|
||||
div class="auth-card" {
|
||||
p class="eyebrow" { "PRIVATE HOUSEHOLD" }
|
||||
p class="eyebrow" { "PRIVATE LISTS" }
|
||||
h1 { "Registration is invite-only" }
|
||||
p class="lede" { "Ask someone who owns a list to send you an invitation link." }
|
||||
p class="lede" { "Ask someone who uses the app to send you an invitation link." }
|
||||
a class="button button-primary" href="/login" { "Back to sign in" }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Markup {
|
||||
pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Markup {
|
||||
page(
|
||||
"Your lists",
|
||||
Some(user),
|
||||
html! {
|
||||
div class="page-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "YOUR HOUSEHOLD" }
|
||||
p class="eyebrow" { "SHARED LISTS" }
|
||||
h1 { "Grocery lists" }
|
||||
p class="lede" { "Everything you need, in one place." }
|
||||
}
|
||||
@@ -104,12 +104,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
|
||||
}
|
||||
} @else {
|
||||
div class="list-cards" {
|
||||
@for summary in lists {
|
||||
a class="list-card" href=(format!("/lists/{}", summary.list.id)) {
|
||||
@for list in lists {
|
||||
a class="list-card" href=(format!("/lists/{}", list.id)) {
|
||||
span class="list-card-icon" { "✓" }
|
||||
span class="list-card-copy" {
|
||||
strong { (summary.list.name) }
|
||||
small { @if summary.role == "owner" { "Owner" } @else { "Member" } }
|
||||
strong { (list.name) }
|
||||
}
|
||||
span class="list-card-arrow" { "→" }
|
||||
}
|
||||
@@ -126,53 +125,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
|
||||
button class="button button-primary" type="submit" { "Create list" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_page(
|
||||
user: &User,
|
||||
access: &ListAccess,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
presence: &[PresenceUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let is_owner = access.role == "owner";
|
||||
page(
|
||||
&access.list.name,
|
||||
Some(user),
|
||||
html! {
|
||||
div class="list-topbar" {
|
||||
a class="back-link" href="/lists" { "← All lists" }
|
||||
div class="list-topbar-actions" {
|
||||
span class="live-pill" { span class="live-dot" {} "Live" }
|
||||
@if is_owner {
|
||||
a class="button button-small button-quiet" href="#sharing" { "Share list" }
|
||||
}
|
||||
}
|
||||
}
|
||||
div class="list-layout" {
|
||||
section class="panel list-panel" {
|
||||
div class="list-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "SHARED LIST" }
|
||||
h1 { (access.list.name) }
|
||||
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
|
||||
}
|
||||
}
|
||||
(list_content_fragment(&access.list, items, categories, csrf_token, false))
|
||||
}
|
||||
aside class="side-column" {
|
||||
(presence_panel(presence, false))
|
||||
(categories_panel(&access.list, categories, csrf_token, false))
|
||||
@if is_owner {
|
||||
section id="sharing" class="panel sharing-panel" {
|
||||
div class="panel-heading" { h2 { "Share this list" } }
|
||||
p { "Create a one-time invite link for someone you shop with." }
|
||||
div class="panel-heading" { h2 { "Invite someone" } }
|
||||
p { "Create a one-time invite link so a new person can join." }
|
||||
form
|
||||
hx-post=(format!("/lists/{}/invitations", access.list.id))
|
||||
hx-post="/invitations"
|
||||
hx-target="#invite-result"
|
||||
hx-swap="innerHTML"
|
||||
class="stack"
|
||||
@@ -183,13 +140,49 @@ pub fn list_page(
|
||||
div id="invite-result" class="invite-result" {}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_page(
|
||||
user: &User,
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
presence: &[PresenceUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
page(
|
||||
&list.name,
|
||||
Some(user),
|
||||
html! {
|
||||
div class="list-topbar" {
|
||||
a class="back-link" href="/lists" { "← All lists" }
|
||||
div class="list-topbar-actions" {
|
||||
span class="live-pill" { span class="live-dot" {} "Live" }
|
||||
}
|
||||
}
|
||||
div class="list-layout" {
|
||||
section class="panel list-panel" {
|
||||
div class="list-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "SHARED LIST" }
|
||||
h1 { (list.name) }
|
||||
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
|
||||
}
|
||||
}
|
||||
(list_content_fragment(list, items, categories, csrf_token, false))
|
||||
}
|
||||
aside class="side-column" {
|
||||
(presence_panel(presence, false))
|
||||
(categories_panel(list, categories, csrf_token, false))
|
||||
section class="panel tip-panel" {
|
||||
span class="tip-label" { "TIP" }
|
||||
p { "Check items off as you go. Everyone viewing this list will see it instantly." }
|
||||
}
|
||||
}
|
||||
}
|
||||
div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", access.list.id)) {}
|
||||
div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", list.id)) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -289,7 +282,7 @@ fn item_groups<'a>(items: &'a [Item], categories: &[Category]) -> Vec<(String, V
|
||||
for category in categories {
|
||||
let items_in_category = items
|
||||
.iter()
|
||||
.filter(|item| item.category_id.as_deref() == Some(category.id.as_str()))
|
||||
.filter(|item| item.category_id == Some(category.id))
|
||||
.collect::<Vec<_>>();
|
||||
if !items_in_category.is_empty() {
|
||||
groups.push((category.name.clone(), items_in_category));
|
||||
@@ -366,7 +359,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
|
||||
input name="note" value=(item.note) maxlength="120";
|
||||
label { "Category" }
|
||||
select name="category_id" {
|
||||
(category_options(categories, item.category_id.as_deref()))
|
||||
(category_options(categories, item.category_id))
|
||||
}
|
||||
button class="button button-small button-secondary" type="submit" { "Save" }
|
||||
}
|
||||
@@ -384,7 +377,7 @@ fn item_row(item: &Item, categories: &[Category], csrf_token: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn category_options(categories: &[Category], selected: Option<&str>) -> Markup {
|
||||
fn category_options(categories: &[Category], selected: Option<i64>) -> Markup {
|
||||
html! {
|
||||
@if selected.is_none() {
|
||||
option value="" selected { "No category" }
|
||||
@@ -392,7 +385,7 @@ fn category_options(categories: &[Category], selected: Option<&str>) -> Markup {
|
||||
option value="" { "No category" }
|
||||
}
|
||||
@for category in categories {
|
||||
@if selected == Some(category.id.as_str()) {
|
||||
@if selected == Some(category.id) {
|
||||
option value=(category.id) selected { (category.name) }
|
||||
} @else {
|
||||
option value=(category.id) { (category.name) }
|
||||
@@ -448,26 +441,26 @@ pub fn categories_panel(
|
||||
}
|
||||
|
||||
pub fn live_list_fragments(
|
||||
access: &ListAccess,
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
html! {
|
||||
(list_content_fragment(&access.list, items, categories, csrf_token, true))
|
||||
(categories_panel(&access.list, categories, csrf_token, true))
|
||||
(list_content_fragment(list, items, categories, csrf_token, true))
|
||||
(categories_panel(list, categories, csrf_token, true))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_created(
|
||||
access: &ListAccess,
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
html! {
|
||||
p class="category-success" { "Category added." }
|
||||
(live_list_fragments(access, items, categories, csrf_token))
|
||||
(live_list_fragments(list, items, categories, csrf_token))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +502,6 @@ fn presence_content(presence: &[PresenceUser]) -> Markup {
|
||||
}
|
||||
|
||||
pub fn invite_page(
|
||||
info: &InvitationInfo,
|
||||
user: Option<&User>,
|
||||
token: &str,
|
||||
error: Option<&str>,
|
||||
@@ -521,8 +513,8 @@ pub fn invite_page(
|
||||
html! {
|
||||
div class="auth-card invite-card" {
|
||||
p class="eyebrow" { "YOU'RE INVITED" }
|
||||
h1 { "Join " (info.list_name) }
|
||||
p class="lede" { "Shop together and keep the list in sync." }
|
||||
h1 { "Join the shared lists" }
|
||||
p class="lede" { "Shop together and keep all the lists in sync." }
|
||||
@if let Some(error) = error {
|
||||
div class="alert alert-error" role="alert" { (error) }
|
||||
}
|
||||
@@ -532,14 +524,15 @@ pub fn invite_page(
|
||||
@if let Some(csrf_token) = csrf_token {
|
||||
input type="hidden" name="csrf" value=(csrf_token);
|
||||
}
|
||||
button class="button button-primary" type="submit" { "Join list" }
|
||||
button class="button button-primary" type="submit" { "Join lists" }
|
||||
}
|
||||
p class="muted" { "Want to join as a different person? Create a new account below." }
|
||||
} @else {
|
||||
p { "Sign in or create an account to accept this invite." }
|
||||
div class="invite-actions" {
|
||||
a class="button button-primary" href=(format!("/login?invite={}", token)) { "Sign in" }
|
||||
a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" }
|
||||
}
|
||||
div class="invite-actions" {
|
||||
a class="button button-primary" href=(format!("/register?invite={}", token)) { "Create account" }
|
||||
a class="button button-secondary" href=(format!("/login?invite={}", token)) { "Sign in" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user