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
+ {#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}