From d6d61df86af9ee423ecfd20f0ae8191ae14728b8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Thu, 18 Jun 2026 00:12:02 +0200 Subject: [PATCH] added more share options and collabs --- backend/migrations/0008_collab.sql | 31 ++ backend/src/auth/session.rs | 26 ++ backend/src/models/mod.rs | 11 + backend/src/routes/collab.rs | 227 +++++++++++++ backend/src/routes/lists.rs | 298 ++++++++++++++--- backend/src/routes/mod.rs | 2 + frontend/src/lib/lists.svelte.ts | 63 +++- .../src/routes/invite/[token]/+page.svelte | 99 ++++++ frontend/src/routes/lists/[id]/+page.svelte | 307 ++++++++++++++++-- .../src/routes/shared/[token]/+page.svelte | 199 +++++++++--- 10 files changed, 1125 insertions(+), 138 deletions(-) create mode 100644 backend/migrations/0008_collab.sql create mode 100644 backend/src/routes/collab.rs create mode 100644 frontend/src/routes/invite/[token]/+page.svelte diff --git a/backend/migrations/0008_collab.sql b/backend/migrations/0008_collab.sql new file mode 100644 index 0000000..8958072 --- /dev/null +++ b/backend/migrations/0008_collab.sql @@ -0,0 +1,31 @@ +-- Collaboration + shared cross-off ("claim") support. + +-- Per-list toggle: may anonymous holders of the share link cross items off? +ALTER TABLE lists ADD COLUMN allow_guest_crossoff BOOLEAN NOT NULL DEFAULT false; + +-- Shared claim on an item: who crossed it off, and when. Visible to everyone +-- who can see the list (gift-registry "this one's taken" semantics). +ALTER TABLE items ADD COLUMN claimed_at TIMESTAMPTZ; +ALTER TABLE items ADD COLUMN claimed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE items ADD COLUMN claimed_by_name TEXT; + +-- Collaboration invite links. Each link carries the role it grants; revoking +-- a link is just deleting the row. Accepting one creates a collaborator. +CREATE TABLE list_invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + role TEXT NOT NULL CHECK (role IN ('editor', 'crosser')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX list_invites_list ON list_invites (list_id); + +-- Accepted collaborators on a list (besides the owner). +CREATE TABLE list_collaborators ( + list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('editor', 'crosser')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (list_id, user_id) +); +CREATE INDEX list_collaborators_user ON list_collaborators (user_id); diff --git a/backend/src/auth/session.rs b/backend/src/auth/session.rs index d0e07f7..5a9f351 100644 --- a/backend/src/auth/session.rs +++ b/backend/src/auth/session.rs @@ -63,3 +63,29 @@ impl FromRequestParts for AuthUser { Ok(AuthUser(user)) } } + +/// Extractor that yields the user if logged in, or None for anonymous callers. +/// Never rejects — used on routes that serve both (e.g. public share links). +pub struct OptionalUser(pub Option); + +impl FromRequestParts for OptionalUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let Some(session) = parts.extensions.get::().cloned() else { + return Ok(OptionalUser(None)); + }; + let id = session + .get::(USER_ID_KEY) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("session read: {e}")))?; + let user = match id { + Some(id) => load_user(&state.pool, id).await?, + None => None, + }; + Ok(OptionalUser(user)) + } +} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 0d53280..4d930f9 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -56,11 +56,17 @@ pub struct List { pub description: Option, /// Unguessable secret for public read-only sharing; None = private. pub share_token: Option, + /// May anonymous holders of the share link cross items off? + pub allow_guest_crossoff: bool, pub position: i32, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub updated_at: OffsetDateTime, + /// Caller's role on this list: "owner" | "editor" | "crosser". Computed per + /// query (not a stored column); queries that omit it leave it None. + #[sqlx(default)] + pub role: Option, } /// A coveted thing inside a list. Price/metadata columns are filled by the @@ -90,6 +96,11 @@ pub struct Item { #[serde(with = "time::serde::rfc3339::option")] pub checked_at: Option, + /// Shared "crossed off"/claimed state. `claimed_at` non-null = taken. + #[serde(with = "time::serde::rfc3339::option")] + pub claimed_at: Option, + pub claimed_by_name: Option, + #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] diff --git a/backend/src/routes/collab.rs b/backend/src/routes/collab.rs new file mode 100644 index 0000000..51cc81e --- /dev/null +++ b/backend/src/routes/collab.rs @@ -0,0 +1,227 @@ +//! Collaboration: per-list invite links that grant a role, and the +//! collaborators who have accepted them. + +use axum::extract::{Path, State}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::auth::session::AuthUser; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route( + "/lists/{id}/invites", + get(list_invites).post(create_invite), + ) + .route("/lists/{id}/invites/{invite_id}", axum::routing::delete(revoke_invite)) + .route("/lists/{id}/collaborators", get(list_collaborators)) + .route( + "/lists/{id}/collaborators/{user_id}", + axum::routing::delete(remove_collaborator), + ) + .route("/invites/{token}", get(preview_invite)) + .route("/invites/{token}/accept", post(accept_invite)) +} + +const ROLES: &[&str] = &["editor", "crosser"]; + +/// Confirm the caller owns the list, else NotFound (hides others' lists). +async fn assert_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> { + let owns = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)", + ) + .bind(list_id) + .bind(user_id) + .fetch_one(&state.pool) + .await?; + if owns { + Ok(()) + } else { + Err(AppError::NotFound) + } +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +struct Invite { + id: Uuid, + token: String, + role: String, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, +} + +async fn list_invites( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult>> { + assert_owner(&state, id, user.id).await?; + let invites = sqlx::query_as::<_, Invite>( + "SELECT id, token, role, created_at FROM list_invites + WHERE list_id = $1 ORDER BY created_at", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + Ok(Json(invites)) +} + +#[derive(Debug, Deserialize)] +struct CreateInviteReq { + role: String, +} + +async fn create_invite( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + assert_owner(&state, id, user.id).await?; + if !ROLES.contains(&req.role.as_str()) { + return Err(AppError::Validation(format!("unknown role: {}", req.role))); + } + let token = Uuid::new_v4().simple().to_string(); + let invite = sqlx::query_as::<_, Invite>( + "INSERT INTO list_invites (list_id, token, role) VALUES ($1, $2, $3) + RETURNING id, token, role, created_at", + ) + .bind(id) + .bind(token) + .bind(&req.role) + .fetch_one(&state.pool) + .await?; + Ok(Json(invite)) +} + +async fn revoke_invite( + State(state): State, + AuthUser(user): AuthUser, + Path((id, invite_id)): Path<(Uuid, Uuid)>, +) -> AppResult> { + assert_owner(&state, id, user.id).await?; + let res = sqlx::query("DELETE FROM list_invites WHERE id = $1 AND list_id = $2") + .bind(invite_id) + .bind(id) + .execute(&state.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(Json(json!({ "deleted": invite_id }))) +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +struct Collaborator { + user_id: Uuid, + role: String, + display_name: Option, + email: String, + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, +} + +async fn list_collaborators( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult>> { + assert_owner(&state, id, user.id).await?; + let rows = sqlx::query_as::<_, Collaborator>( + "SELECT c.user_id, c.role, u.display_name, u.email, c.created_at + FROM list_collaborators c JOIN users u ON u.id = c.user_id + WHERE c.list_id = $1 ORDER BY c.created_at", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + Ok(Json(rows)) +} + +async fn remove_collaborator( + State(state): State, + AuthUser(user): AuthUser, + Path((id, target)): Path<(Uuid, Uuid)>, +) -> AppResult> { + assert_owner(&state, id, user.id).await?; + let res = sqlx::query("DELETE FROM list_collaborators WHERE list_id = $1 AND user_id = $2") + .bind(id) + .bind(target) + .execute(&state.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(Json(json!({ "removed": target }))) +} + +/// What an invitee sees before accepting: which list, what role, and whether +/// they're the owner or already a collaborator. +#[derive(Debug, Serialize, sqlx::FromRow)] +struct InvitePreview { + list_id: Uuid, + list_name: String, + emoji: Option, + role: String, + owner_id: Uuid, +} + +async fn preview_invite( + State(state): State, + Path(token): Path, +) -> AppResult> { + let p = sqlx::query_as::<_, InvitePreview>( + "SELECT l.id AS list_id, l.name AS list_name, l.emoji, inv.role, l.user_id AS owner_id + FROM list_invites inv JOIN lists l ON l.id = inv.list_id + WHERE inv.token = $1", + ) + .bind(&token) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + + Ok(Json(json!({ + "list_id": p.list_id, + "list_name": p.list_name, + "emoji": p.emoji, + "role": p.role, + }))) +} + +/// Accept an invite: become a collaborator on its list with the invite's role. +/// Idempotent — re-accepting updates the role. Owners are a no-op success. +async fn accept_invite( + State(state): State, + AuthUser(user): AuthUser, + Path(token): Path, +) -> AppResult> { + let (list_id, role, owner_id) = sqlx::query_as::<_, (Uuid, String, Uuid)>( + "SELECT l.id, inv.role, l.user_id + FROM list_invites inv JOIN lists l ON l.id = inv.list_id + WHERE inv.token = $1", + ) + .bind(&token) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + + if owner_id != user.id { + sqlx::query( + "INSERT INTO list_collaborators (list_id, user_id, role) VALUES ($1, $2, $3) + ON CONFLICT (list_id, user_id) DO UPDATE SET role = EXCLUDED.role", + ) + .bind(list_id) + .bind(user.id) + .bind(&role) + .execute(&state.pool) + .await?; + } + + Ok(Json(json!({ "list_id": list_id, "role": role }))) +} diff --git a/backend/src/routes/lists.rs b/backend/src/routes/lists.rs index 13fa825..5650cfd 100644 --- a/backend/src/routes/lists.rs +++ b/backend/src/routes/lists.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use uuid::Uuid; use validator::Validate; -use crate::auth::session::AuthUser; +use crate::auth::session::{AuthUser, OptionalUser}; use crate::error::{AppError, AppResult}; use crate::models::{Item, List, PricePoint}; use crate::state::AppState; @@ -21,28 +21,41 @@ pub fn router() -> Router { ) .route("/lists/{id}/share", post(share_list).delete(unshare_list)) .route("/shared/{token}", get(shared_view)) + .route( + "/shared/{token}/items/{item_id}/history", + get(shared_item_history), + ) + .route( + "/shared/{token}/items/{item_id}/claim", + post(guest_claim).delete(guest_unclaim), + ) .route("/lists/{id}/items", get(list_items).post(create_item)) .route( "/items/{id}", axum::routing::patch(update_item).delete(delete_item), ) + .route("/items/{id}/claim", post(claim_item).delete(unclaim_item)) .route("/items/{id}/refetch", post(refetch_item)) .route("/items/{id}/history", get(item_history)) } pub const ITEM_COLS: &str = "id, list_id, title, url, note, status::text AS status, target_price, \ position, title_fetched, current_price, currency, image_url, in_stock, source, fetched_at, \ - track_enabled, last_error, checked_at, created_at, updated_at"; + track_enabled, last_error, checked_at, claimed_at, claimed_by_name, created_at, updated_at"; // Same columns, qualified with the `i` alias for use in UPDATE … FROM lists, // where bare `id`/`position`/`created_at` would be ambiguous across both tables. const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::text AS status, \ i.target_price, i.position, i.title_fetched, i.current_price, i.currency, i.image_url, \ i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \ - i.created_at, i.updated_at"; + i.claimed_at, i.claimed_by_name, i.created_at, i.updated_at"; -const LIST_COLS: &str = - "id, user_id, name, emoji, description, share_token, position, created_at, updated_at"; +const LIST_COLS: &str = "id, user_id, name, emoji, description, share_token, allow_guest_crossoff, \ + position, created_at, updated_at"; + +// Same, prefixed with the `l` alias (for the collaborator UNION arm). +const LIST_COLS_L: &str = "l.id, l.user_id, l.name, l.emoji, l.description, l.share_token, \ + l.allow_guest_crossoff, l.position, l.created_at, l.updated_at"; const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"]; @@ -52,9 +65,16 @@ async fn list_lists( State(state): State, AuthUser(user): AuthUser, ) -> AppResult>> { + // Lists you own, plus lists you've been invited to collaborate on. The + // `role` column tells the client what controls to show. let lists = sqlx::query_as::<_, List>(&format!( - "SELECT {LIST_COLS} - FROM lists WHERE user_id = $1 ORDER BY position, created_at" + "SELECT {LIST_COLS}, 'owner'::text AS role + FROM lists WHERE user_id = $1 + UNION ALL + SELECT {LIST_COLS_L}, c.role + FROM lists l JOIN list_collaborators c ON c.list_id = l.id + WHERE c.user_id = $1 + ORDER BY position, created_at" )) .bind(user.id) .fetch_all(&state.pool) @@ -84,7 +104,7 @@ async fn create_list( "INSERT INTO lists (user_id, name, emoji, description, position) VALUES ($1, $2, $3, $4, COALESCE((SELECT MAX(position) + 1 FROM lists WHERE user_id = $1), 0)) - RETURNING {LIST_COLS}" + RETURNING {LIST_COLS}, 'owner'::text AS role" )) .bind(user.id) .bind(req.name.trim()) @@ -104,6 +124,8 @@ struct UpdateListReq { #[validate(length(max = 500))] description: Option, position: Option, + /// List setting: allow anonymous share-link holders to cross items off. + allow_guest_crossoff: Option, } async fn update_list( @@ -117,12 +139,13 @@ async fn update_list( let list = sqlx::query_as::<_, List>(&format!( "UPDATE lists SET - name = COALESCE($3, name), - emoji = COALESCE($4, emoji), - description = COALESCE($5, description), - position = COALESCE($6, position) + name = COALESCE($3, name), + emoji = COALESCE($4, emoji), + description = COALESCE($5, description), + position = COALESCE($6, position), + allow_guest_crossoff = COALESCE($7, allow_guest_crossoff) WHERE id = $1 AND user_id = $2 - RETURNING {LIST_COLS}" + RETURNING {LIST_COLS}, 'owner'::text AS role" )) .bind(id) .bind(user.id) @@ -130,6 +153,7 @@ async fn update_list( .bind(opt_trim(req.emoji)) .bind(opt_trim(req.description)) .bind(req.position) + .bind(req.allow_guest_crossoff) .fetch_optional(&state.pool) .await? .ok_or(AppError::NotFound)?; @@ -165,7 +189,7 @@ async fn share_list( let list = sqlx::query_as::<_, List>(&format!( "UPDATE lists SET share_token = COALESCE(share_token, $3) WHERE id = $1 AND user_id = $2 - RETURNING {LIST_COLS}" + RETURNING {LIST_COLS}, 'owner'::text AS role" )) .bind(id) .bind(user.id) @@ -185,7 +209,7 @@ async fn unshare_list( let list = sqlx::query_as::<_, List>(&format!( "UPDATE lists SET share_token = NULL WHERE id = $1 AND user_id = $2 - RETURNING {LIST_COLS}" + RETURNING {LIST_COLS}, 'owner'::text AS role" )) .bind(id) .bind(user.id) @@ -219,30 +243,63 @@ async fn shared_view( Ok(Json(serde_json::json!({ "list": list, "items": items }))) } -// ---- Items ---------------------------------------------------------------- +// ---- Access control ------------------------------------------------------- -/// Confirm the list exists and belongs to the user. Returns NotFound otherwise. -async fn assert_list_owner(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> { - let owns = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM lists WHERE id = $1 AND user_id = $2)", +/// The caller's role on a list: "owner", "editor", "crosser", or None (no +/// access). Owner wins over any collaborator row. +async fn list_role( + state: &AppState, + list_id: Uuid, + user_id: Uuid, +) -> AppResult> { + let role = sqlx::query_scalar::<_, String>( + "SELECT 'owner' FROM lists WHERE id = $1 AND user_id = $2 + UNION ALL + SELECT role FROM list_collaborators WHERE list_id = $1 AND user_id = $2 + LIMIT 1", ) .bind(list_id) .bind(user_id) - .fetch_one(&state.pool) + .fetch_optional(&state.pool) .await?; - if owns { - Ok(()) - } else { - Err(AppError::NotFound) + Ok(role) +} + +/// Owner/editor/crosser may view. NotFound hides lists you can't see. +async fn assert_can_view(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> { + match list_role(state, list_id, user_id).await? { + Some(_) => Ok(()), + None => Err(AppError::NotFound), } } +/// Owner/editor may add/edit/delete items. Crossers see Forbidden (they can +/// view but not mutate the catalogue), strangers NotFound. +async fn assert_can_edit(state: &AppState, list_id: Uuid, user_id: Uuid) -> AppResult<()> { + match list_role(state, list_id, user_id).await?.as_deref() { + Some("owner") | Some("editor") => Ok(()), + Some(_) => Err(AppError::Forbidden), + None => Err(AppError::NotFound), + } +} + +/// The list an item belongs to, or NotFound. +async fn item_list_id(state: &AppState, item_id: Uuid) -> AppResult { + sqlx::query_scalar::<_, Uuid>("SELECT list_id FROM items WHERE id = $1") + .bind(item_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound) +} + +// ---- Items ---------------------------------------------------------------- + async fn list_items( State(state): State, AuthUser(user): AuthUser, Path(list_id): Path, ) -> AppResult>> { - assert_list_owner(&state, list_id, user.id).await?; + assert_can_view(&state, list_id, user.id).await?; let items = sqlx::query_as::<_, Item>(&format!( "SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at" )) @@ -271,7 +328,7 @@ async fn create_item( ) -> AppResult> { req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; - assert_list_owner(&state, list_id, user.id).await?; + assert_can_edit(&state, list_id, user.id).await?; let item = sqlx::query_as::<_, Item>(&format!( "INSERT INTO items (list_id, title, url, note, target_price, position) @@ -323,6 +380,10 @@ async fn update_item( } } + // Owner or editor only (crossers cross off, they don't edit). + let list_id = item_list_id(&state, id).await?; + assert_can_edit(&state, list_id, user.id).await?; + let currency = req .currency .map(|c| c.trim().to_uppercase()) @@ -332,8 +393,8 @@ async fn update_item( let set_target = req.target_price.is_some(); let target_val = req.target_price.flatten(); - // Ownership enforced via the join to lists.user_id. A currency override - // both latches (currency_override) and takes effect immediately (currency). + // Access already checked above. A currency override both latches + // (currency_override) and takes effect immediately (currency). let item = sqlx::query_as::<_, Item>(&format!( "UPDATE items i SET title = COALESCE($3, i.title), @@ -345,7 +406,7 @@ async fn update_item( currency_override = COALESCE($10, i.currency_override), currency = COALESCE($10, i.currency) FROM lists l - WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2 + WHERE i.id = $1 AND i.list_id = l.id RETURNING {ITEM_COLS_I}" )) .bind(id) @@ -369,14 +430,13 @@ async fn delete_item( AuthUser(user): AuthUser, Path(id): Path, ) -> AppResult> { - let res = sqlx::query( - "DELETE FROM items i USING lists l - WHERE i.id = $1 AND i.list_id = l.id AND l.user_id = $2", - ) - .bind(id) - .bind(user.id) - .execute(&state.pool) - .await?; + let list_id = item_list_id(&state, id).await?; + assert_can_edit(&state, list_id, user.id).await?; + + let res = sqlx::query("DELETE FROM items WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await?; if res.rows_affected() == 0 { return Err(AppError::NotFound); } @@ -385,22 +445,20 @@ async fn delete_item( // ---- Tracking ------------------------------------------------------------- -/// Owned item's URL, or NotFound. Inner Option is the (nullable) url. -async fn owned_item_url( +/// A viewable item's URL (owner or any collaborator), or NotFound. Inner +/// Option is the (nullable) url. +async fn viewable_item_url( state: &AppState, item_id: Uuid, user_id: Uuid, ) -> AppResult> { - let row = sqlx::query_as::<_, (Option,)>( - "SELECT i.url FROM items i JOIN lists l ON l.id = i.list_id - WHERE i.id = $1 AND l.user_id = $2", - ) - .bind(item_id) - .bind(user_id) - .fetch_optional(&state.pool) - .await? - .ok_or(AppError::NotFound)?; - Ok(row.0) + let list_id = item_list_id(state, item_id).await?; + assert_can_view(state, list_id, user_id).await?; + let url = sqlx::query_scalar::<_, Option>("SELECT url FROM items WHERE id = $1") + .bind(item_id) + .fetch_one(&state.pool) + .await?; + Ok(url) } /// Refetch a single item's price on demand. Surfaces fetch errors to the user. @@ -409,7 +467,7 @@ async fn refetch_item( AuthUser(user): AuthUser, Path(id): Path, ) -> AppResult> { - let url = owned_item_url(&state, id, user.id).await?.ok_or_else(|| { + let url = viewable_item_url(&state, id, user.id).await?.ok_or_else(|| { AppError::BadRequest("this temptation has no URL to keep vigil over".into()) })?; @@ -431,7 +489,7 @@ async fn item_history( Path(id): Path, ) -> AppResult>> { // Ownership: NotFound if the item isn't the user's. - owned_item_url(&state, id, user.id).await?; + viewable_item_url(&state, id, user.id).await?; let history = sqlx::query_as::<_, PricePoint>( "SELECT price, currency, in_stock, fetched_at @@ -444,6 +502,142 @@ async fn item_history( Ok(Json(history)) } +// ---- Cross off / claim ---------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct ClaimReq { + /// Display name to record. Required for anonymous guests; logged-in users + /// fall back to their account name. + #[serde(default)] + name: Option, +} + +/// Set or clear an item's shared claim, returning the updated row. +async fn set_claim( + state: &AppState, + item_id: Uuid, + user_id: Option, + name: Option, +) -> AppResult { + let claimed = name.is_some(); + let item = sqlx::query_as::<_, Item>(&format!( + "UPDATE items SET + claimed_at = CASE WHEN $2 THEN now() ELSE NULL END, + claimed_by_user_id = $3, + claimed_by_name = $4 + WHERE id = $1 + RETURNING {ITEM_COLS}" + )) + .bind(item_id) + .bind(claimed) + .bind(user_id) + .bind(name) + .fetch_one(&state.pool) + .await?; + Ok(item) +} + +/// Cross an item off via the list (any collaborator: owner/editor/crosser). +async fn claim_item( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + let list_id = item_list_id(&state, id).await?; + assert_can_view(&state, list_id, user.id).await?; + + let name = opt_trim(req.name) + .or_else(|| user.display_name.clone()) + .unwrap_or_else(|| user.email.clone()); + Ok(Json(set_claim(&state, id, Some(user.id), Some(name)).await?)) +} + +async fn unclaim_item( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let list_id = item_list_id(&state, id).await?; + assert_can_view(&state, list_id, user.id).await?; + Ok(Json(set_claim(&state, id, None, None).await?)) +} + +/// True if the item belongs to the shared list named by `token` and that list +/// has guest cross-off enabled. +async fn guest_crossoff_ok(state: &AppState, token: &str, item_id: Uuid) -> AppResult<()> { + let ok = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM items i JOIN lists l ON l.id = i.list_id + WHERE i.id = $1 AND l.share_token = $2 AND l.allow_guest_crossoff)", + ) + .bind(item_id) + .bind(token) + .fetch_one(&state.pool) + .await?; + if ok { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +/// Cross off via a public share link (anonymous or logged-in). Gated by the +/// list's `allow_guest_crossoff` setting. +async fn guest_claim( + State(state): State, + OptionalUser(user): OptionalUser, + Path((token, item_id)): Path<(String, Uuid)>, + Json(req): Json, +) -> AppResult> { + guest_crossoff_ok(&state, &token, item_id).await?; + // Don't leak an account email on a public list; require a typed name if the + // user has no display name set. + let name = opt_trim(req.name) + .or_else(|| user.as_ref().and_then(|u| u.display_name.clone())) + .ok_or_else(|| AppError::BadRequest("add your name so others know who claimed it".into()))?; + let uid = user.as_ref().map(|u| u.id); + Ok(Json(set_claim(&state, item_id, uid, Some(name)).await?)) +} + +async fn guest_unclaim( + State(state): State, + OptionalUser(_user): OptionalUser, + Path((token, item_id)): Path<(String, Uuid)>, +) -> AppResult> { + guest_crossoff_ok(&state, &token, item_id).await?; + Ok(Json(set_claim(&state, item_id, None, None).await?)) +} + +/// Public price history for an item on a shared list (drives the chart on the +/// shared view). Holding the share token is the only credential. +async fn shared_item_history( + State(state): State, + Path((token, item_id)): Path<(String, Uuid)>, +) -> AppResult>> { + let belongs = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS( + SELECT 1 FROM items i JOIN lists l ON l.id = i.list_id + WHERE i.id = $1 AND l.share_token = $2)", + ) + .bind(item_id) + .bind(token) + .fetch_one(&state.pool) + .await?; + if !belongs { + return Err(AppError::NotFound); + } + let history = sqlx::query_as::<_, PricePoint>( + "SELECT price, currency, in_stock, fetched_at + FROM price_history WHERE item_id = $1 + ORDER BY fetched_at DESC LIMIT 200", + ) + .bind(item_id) + .fetch_all(&state.pool) + .await?; + Ok(Json(history)) +} + fn opt_trim(s: Option) -> Option { s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index e5970ac..6fa9491 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -10,6 +10,7 @@ use crate::error::{AppError, AppResult}; use crate::models::UserSettings; use crate::state::AppState; +mod collab; mod lists; mod subs; @@ -19,6 +20,7 @@ pub fn router() -> Router { .route("/settings", patch(update_settings)) .route("/profile", patch(update_profile)) .merge(lists::router()) + .merge(collab::router()) .merge(subs::router()) } diff --git a/frontend/src/lib/lists.svelte.ts b/frontend/src/lib/lists.svelte.ts index e1cc8f0..f2dd8ac 100644 --- a/frontend/src/lib/lists.svelte.ts +++ b/frontend/src/lib/lists.svelte.ts @@ -2,19 +2,46 @@ import { api } from "./api"; export type ItemStatus = "coveted" | "acquired" | "renounced"; +export type ListRole = "owner" | "editor" | "crosser"; + export type List = { id: string; name: string; emoji: string | null; description: string | null; share_token: string | null; + allow_guest_crossoff: boolean; position: number; created_at: string; updated_at: string; + /** Caller's role on this list; null on public views. */ + role: ListRole | null; }; export type SharedView = { list: List; items: Item[] }; +export type Invite = { + id: string; + token: string; + role: "editor" | "crosser"; + created_at: string; +}; + +export type Collaborator = { + user_id: string; + role: "editor" | "crosser"; + display_name: string | null; + email: string; + created_at: string; +}; + +export type InvitePreview = { + list_id: string; + list_name: string; + emoji: string | null; + role: "editor" | "crosser"; +}; + export type Subscription = { id: string; kind: "list" | "item"; @@ -52,6 +79,9 @@ export type Item = { track_enabled: boolean; last_error: string | null; checked_at: string | null; + // Shared "crossed off"/claim state. + claimed_at: string | null; + claimed_by_name: string | null; created_at: string; updated_at: string; }; @@ -81,12 +111,41 @@ export type NewItem = { export const listsApi = { all: () => api.get("/lists"), create: (b: NewList) => api.post("/lists", b), - update: (id: string, b: Partial & { position?: number }) => - api.patch(`/lists/${id}`, b), + update: ( + id: string, + b: Partial & { position?: number; allow_guest_crossoff?: boolean }, + ) => api.patch(`/lists/${id}`, b), remove: (id: string) => api.del<{ deleted: string }>(`/lists/${id}`), share: (id: string) => api.post(`/lists/${id}/share`, {}), unshare: (id: string) => api.del(`/lists/${id}/share`), shared: (token: string) => api.get(`/shared/${token}`), + sharedHistory: (token: string, itemId: string) => + api.get(`/shared/${token}/items/${itemId}/history`), + + // Cross off / claim (collaborator route). + claim: (id: string, name?: string) => + api.post(`/items/${id}/claim`, name ? { name } : {}), + unclaim: (id: string) => api.del(`/items/${id}/claim`), + // Cross off via public share link (guest crossoff must be enabled). + guestClaim: (token: string, itemId: string, name: string) => + api.post(`/shared/${token}/items/${itemId}/claim`, { name }), + guestUnclaim: (token: string, itemId: string) => + api.del(`/shared/${token}/items/${itemId}/claim`), + + // Collaboration: invites + collaborators. + invites: (listId: string) => api.get(`/lists/${listId}/invites`), + createInvite: (listId: string, role: "editor" | "crosser") => + api.post(`/lists/${listId}/invites`, { role }), + revokeInvite: (listId: string, inviteId: string) => + api.del<{ deleted: string }>(`/lists/${listId}/invites/${inviteId}`), + collaborators: (listId: string) => + api.get(`/lists/${listId}/collaborators`), + removeCollaborator: (listId: string, userId: string) => + api.del<{ removed: string }>(`/lists/${listId}/collaborators/${userId}`), + previewInvite: (token: string) => + api.get(`/invites/${token}`), + acceptInvite: (token: string) => + api.post<{ list_id: string; role: string }>(`/invites/${token}/accept`, {}), items: (listId: string) => api.get(`/lists/${listId}/items`), addItem: (listId: string, b: NewItem) => diff --git a/frontend/src/routes/invite/[token]/+page.svelte b/frontend/src/routes/invite/[token]/+page.svelte new file mode 100644 index 0000000..a3c8f0e --- /dev/null +++ b/frontend/src/routes/invite/[token]/+page.svelte @@ -0,0 +1,99 @@ + + +collaborate · consume·rs + +
+ {#if !loaded} +

loading…

+ {:else if error} +
+

invite broken

+

{error}

+ ← your lists +
+ {:else if preview} +
+

you're invited

+
+ {preview.emoji ?? '✦'} +
+

{preview.list_name}

+

collaborate as {preview.role}

+
+
+

+ accepting lets you {roleBlurb(preview.role)} on this list. + it'll appear under your lists. +

+ + {#if auth.loaded && auth.user} + + {:else if auth.loaded} +

log in or sign up to accept this invite.

+ + {/if} +
+ {/if} +
diff --git a/frontend/src/routes/lists/[id]/+page.svelte b/frontend/src/routes/lists/[id]/+page.svelte index 74b69d6..48aaa64 100644 --- a/frontend/src/routes/lists/[id]/+page.svelte +++ b/frontend/src/routes/lists/[id]/+page.svelte @@ -7,9 +7,12 @@ import { lists, listsApi, + type Collaborator, + type Invite, type Item, type ItemStatus, type List, + type ListRole, type PricePoint } from '$lib/lists.svelte'; @@ -20,6 +23,12 @@ let loaded = $state(false); let loadError = $state(''); + // Role gates the controls: owners see everything, editors can edit items, + // crossers can only cross items off. + const role = $derived(list?.role ?? 'owner'); + const canEdit = $derived(role === 'owner' || role === 'editor'); + const isOwner = $derived(role === 'owner'); + // form let title = $state(''); let url = $state(''); @@ -77,6 +86,112 @@ } } + // cross off / claim + let claimBusy = $state(null); + async function toggleClaim(item: Item) { + claimBusy = item.id; + formError = ''; + try { + const updated = item.claimed_at + ? await listsApi.unclaim(item.id) + : await listsApi.claim(item.id); + const i = items.findIndex((x) => x.id === item.id); + if (i >= 0) items[i] = updated; + } catch (err) { + formError = err instanceof ApiError ? err.message : 'failed to update'; + } finally { + claimBusy = null; + } + } + + // list settings + collaboration (owner only) + let showSettings = $state(false); + let settingsBusy = $state(false); + let invites = $state([]); + let collaborators = $state([]); + let collabLoaded = $state(false); + let copiedInvite = $state(null); + const inviteUrl = (token: string) => `${page.url.origin}/invite/${token}`; + + async function loadCollab() { + if (!list || collabLoaded) return; + try { + [invites, collaborators] = await Promise.all([ + listsApi.invites(list.id), + listsApi.collaborators(list.id) + ]); + collabLoaded = true; + } catch (err) { + formError = err instanceof ApiError ? err.message : 'failed to load collaborators'; + } + } + + async function toggleSettings() { + showSettings = !showSettings; + if (showSettings) await loadCollab(); + } + + async function toggleGuestCrossoff() { + if (!list) return; + settingsBusy = true; + formError = ''; + try { + const updated = await listsApi.update(list.id, { + allow_guest_crossoff: !list.allow_guest_crossoff + }); + list = updated; + lists.replace(updated); + } catch (err) { + formError = err instanceof ApiError ? err.message : 'failed to save setting'; + } finally { + settingsBusy = false; + } + } + + async function makeInvite(r: 'editor' | 'crosser') { + if (!list) return; + settingsBusy = true; + formError = ''; + try { + const inv = await listsApi.createInvite(list.id, r); + invites.push(inv); + } catch (err) { + formError = err instanceof ApiError ? err.message : 'failed to create invite'; + } finally { + settingsBusy = false; + } + } + + async function dropInvite(inviteId: string) { + if (!list) return; + try { + await listsApi.revokeInvite(list.id, inviteId); + invites = invites.filter((i) => i.id !== inviteId); + } catch (err) { + formError = err instanceof ApiError ? err.message : 'failed to revoke'; + } + } + + async function dropCollaborator(userId: string) { + if (!list || !confirm('remove this collaborator? they lose access immediately.')) return; + try { + await listsApi.removeCollaborator(list.id, userId); + collaborators = collaborators.filter((c) => c.user_id !== userId); + } catch (err) { + formError = err instanceof ApiError ? err.message : 'failed to remove'; + } + } + + async function copyInvite(token: string) { + try { + await navigator.clipboard.writeText(inviteUrl(token)); + copiedInvite = token; + setTimeout(() => (copiedInvite = null), 1500); + } catch { + /* clipboard blocked */ + } + } + // inline edit let editingId = $state(null); let edit = $state({ title: '', url: '', note: '', target: '', currency: '' }); @@ -296,19 +411,35 @@ {#if list?.description}

{list.description}

{/if} {#if list} - {#if list.share_token} - - {:else} - - {/if} +
+ {#if !isOwner} + + {role === 'editor' ? '✎ editor' : '☑ cross-off'} + + {/if} + {#if isOwner} + {#if list.share_token} + + {:else} + + {/if} + + {/if} +
{/if} @@ -325,9 +456,86 @@ {/if} + + {#if isOwner && showSettings} +
+
+

list settings

+ +
+ + +
+
+

let anyone with the link cross items off

+

+ for gift/birthday lists — visitors don't need an account to mark something as taken. + {#if !list?.share_token}share the list first to use this.{/if} +

+
+ +
+ + +
+

invite collaborators

+

+ create a link, then share it. whoever opens it (and logs in) joins with that role. +

+
+ + +
+ + {#if invites.length} +
    + {#each invites as inv (inv.id)} +
  • + {inv.role} + e.currentTarget.select()} /> + + +
  • + {/each} +
+ {/if} +
+ + + {#if collaborators.length} +
+

collaborators

+
    + {#each collaborators as c (c.user_id)} +
  • + + {c.display_name ?? c.email} + {c.role} + + +
  • + {/each} +
+
+ {/if} +
+ {/if} + {#if canEdit}
@@ -351,6 +559,9 @@ {/if} {#if formError}

{formError}

{/if} + {:else if formError} +

{formError}

+ {/if} {#if !loaded}

loading items…

@@ -377,7 +588,10 @@ {/if}
-

+

{item.title_fetched ?? item.title}

{#if item.in_stock === true} @@ -406,16 +620,35 @@ checked {fmtDate(item.checked_at)} {/if}
+ {#if item.claimed_at} +

+ ☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if} +

+ {/if}
- - + +
+ + {#if canEdit} + + {:else} + {STATUS_LABEL[item.status]} + {/if} +
@@ -437,20 +670,22 @@ {historyFor === item.id ? 'hide history' : 'history'} {/if} - - + {#if canEdit} + + + {/if} {#if editingId === item.id} diff --git a/frontend/src/routes/shared/[token]/+page.svelte b/frontend/src/routes/shared/[token]/+page.svelte index 9788473..c7ec2e7 100644 --- a/frontend/src/routes/shared/[token]/+page.svelte +++ b/frontend/src/routes/shared/[token]/+page.svelte @@ -3,7 +3,8 @@ import { page } from '$app/state'; import { ApiError } from '$lib/api'; import { auth } from '$lib/auth.svelte'; - import { listsApi, subs, type Item, type List } from '$lib/lists.svelte'; + import PriceChart from '$lib/PriceChart.svelte'; + import { listsApi, subs, type Item, type List, type PricePoint } from '$lib/lists.svelte'; const token = $derived(page.params.token); @@ -13,6 +14,61 @@ let loadError = $state(''); let subBusy = $state(null); + // Cross-off / claim (public route, gated by the list's guest-crossoff setting). + const canGuestCross = $derived(!!list?.allow_guest_crossoff); + let guestName = $state(''); + $effect(() => { + if (typeof localStorage !== 'undefined' && !guestName) { + guestName = localStorage.getItem('guestName') ?? ''; + } + }); + + let claimBusy = $state(null); + async function toggleClaim(item: Item) { + if (!token) return; + const name = (auth.user?.display_name ?? guestName).trim(); + if (!item.claimed_at && !name) { + loadError = 'enter your name first so others know who took it'; + return; + } + if (guestName.trim()) localStorage.setItem('guestName', guestName.trim()); + claimBusy = item.id; + loadError = ''; + try { + const updated = item.claimed_at + ? await listsApi.guestUnclaim(token, item.id) + : await listsApi.guestClaim(token, item.id, name); + const i = items.findIndex((x) => x.id === item.id); + if (i >= 0) items[i] = updated; + } catch (e) { + loadError = e instanceof ApiError ? e.message : 'failed to update'; + } finally { + claimBusy = null; + } + } + + // Price history chart (public endpoint). + let historyFor = $state(null); + let history = $state([]); + let historyLoading = $state(false); + async function toggleHistory(item: Item) { + if (!token) return; + if (historyFor === item.id) { + historyFor = null; + return; + } + historyFor = item.id; + history = []; + historyLoading = true; + try { + history = await listsApi.sharedHistory(token, item.id); + } catch { + /* chart just stays empty */ + } finally { + historyLoading = false; + } + } + let lastToken = ''; $effect(() => { if (token && token !== lastToken) { @@ -144,6 +200,17 @@

+ {#if canGuestCross} +
+ cross items off + {#if auth.user} + you can tick items as taken below. + {:else} + + {/if} +
+ {/if} + {#if visible.length === 0}

nothing here yet

@@ -153,59 +220,95 @@
    {#each visible as item (item.id)}
  • - {#if item.image_url} - - {/if} -
    -
    -

    {item.title_fetched ?? item.title}

    - {#if item.in_stock === true} - in stock - {:else if item.in_stock === false} - sold out - {/if} -
    -
    - {#if money(item.current_price, item.currency)} - - {money(item.current_price, item.currency)} - - {/if} - {#if item.target_price != null} - target {money(item.target_price, item.currency)} - {/if} - {#if item.url} - visit ↗ - {/if} - {#if item.note}“{item.note}”{/if} -
    - {#if onSale(item)} -

    ✦ on sale — below target

    +
    + {#if item.image_url} + {/if} +
    +
    +

    + {item.title_fetched ?? item.title} +

    + {#if item.in_stock === true} + in stock + {:else if item.in_stock === false} + sold out + {/if} +
    +
    + {#if money(item.current_price, item.currency)} + + {money(item.current_price, item.currency)} + + {/if} + {#if item.target_price != null} + target {money(item.target_price, item.currency)} + {/if} + {#if item.url} + visit ↗ + {/if} + {#if item.note}“{item.note}”{/if} + {#if item.url} + + {/if} +
    + {#if item.claimed_at} +

    + ☑ taken{#if item.claimed_by_name} by {item.claimed_by_name}{/if} +

    + {/if} + {#if onSale(item)} +

    ✦ on sale — below target

    + {/if} +
    + +
    + {#if canGuestCross} + + {/if} + {#if auth.user && subs.forItem(item.id)} + + {:else} + + {/if} +
    - {#if auth.user && subs.forItem(item.id)} - - {:else} - + {#if historyFor === item.id} +
    + {#if historyLoading} +

    loading chart…

    + {:else} + + {/if} +
    {/if}
  • {/each}