From 8a614cb1d14e3fee5c70fdd8a5422549786b14db Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 17 Jun 2026 23:27:37 +0200 Subject: [PATCH] added sharing and subscriptions --- backend/migrations/0006_share.sql | 6 + backend/migrations/0007_subscriptions.sql | 31 +++ backend/src/models/mod.rs | 2 + backend/src/notify.rs | 136 +++++++++++ backend/src/routes/lists.rs | 92 +++++++- backend/src/routes/mod.rs | 2 + backend/src/routes/subs.rs | 181 +++++++++++++++ backend/src/worker.rs | 1 + frontend/src/lib/PriceChart.svelte | 38 +++ frontend/src/lib/lists.svelte.ts | 74 ++++++ frontend/src/routes/+layout.svelte | 1 + frontend/src/routes/lists/[id]/+page.svelte | 114 ++++++++- frontend/src/routes/login/+page.svelte | 14 +- frontend/src/routes/register/+page.svelte | 12 +- .../src/routes/shared/[token]/+page.svelte | 219 ++++++++++++++++++ .../src/routes/subscriptions/+page.svelte | 122 ++++++++++ 16 files changed, 1019 insertions(+), 26 deletions(-) create mode 100644 backend/migrations/0006_share.sql create mode 100644 backend/migrations/0007_subscriptions.sql create mode 100644 backend/src/routes/subs.rs create mode 100644 frontend/src/routes/shared/[token]/+page.svelte create mode 100644 frontend/src/routes/subscriptions/+page.svelte diff --git a/backend/migrations/0006_share.sql b/backend/migrations/0006_share.sql new file mode 100644 index 0000000..30949d5 --- /dev/null +++ b/backend/migrations/0006_share.sql @@ -0,0 +1,6 @@ +-- Public read-only sharing for lists. +-- NULL share_token = private (default). A non-NULL token is an unguessable +-- secret: anyone holding it can view the list read-only at /api/shared/{token}, +-- no account required. Revoking (unshare) sets it back to NULL. +ALTER TABLE lists + ADD COLUMN share_token TEXT UNIQUE; diff --git a/backend/migrations/0007_subscriptions.sql b/backend/migrations/0007_subscriptions.sql new file mode 100644 index 0000000..697f7d5 --- /dev/null +++ b/backend/migrations/0007_subscriptions.sql @@ -0,0 +1,31 @@ +-- Subscriptions: a logged-in user follows someone else's shared list or a +-- single shared item, to receive the same price-drop emails the owner gets. +-- Exactly one of list_id / item_id is set per row. A list subscription +-- implicitly covers every item on that list — present and future — expanded +-- at notify time rather than materialised per item. +CREATE TABLE subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + list_id UUID REFERENCES lists(id) ON DELETE CASCADE, + item_id UUID REFERENCES items(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT subscription_target_exactly_one + CHECK ((list_id IS NOT NULL) <> (item_id IS NOT NULL)) +); + +-- One subscription per (user, list) and per (user, item). +CREATE UNIQUE INDEX subscriptions_user_list + ON subscriptions (user_id, list_id) WHERE list_id IS NOT NULL; +CREATE UNIQUE INDEX subscriptions_user_item + ON subscriptions (user_id, item_id) WHERE item_id IS NOT NULL; + +-- Per-subscriber, per-item de-dupe latch for target-price alerts — the +-- subscriber-side mirror of items.notified_at (which latches the owner). +-- Present = already announced this drop; absent = armed. Cleared when the +-- price climbs back above the item's target. +CREATE TABLE subscription_notify ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + notified_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, item_id) +); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 4eae2a7..0d53280 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -54,6 +54,8 @@ pub struct List { pub name: String, pub emoji: Option, pub description: Option, + /// Unguessable secret for public read-only sharing; None = private. + pub share_token: Option, pub position: i32, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, diff --git a/backend/src/notify.rs b/backend/src/notify.rs index 01f876d..8112c4b 100644 --- a/backend/src/notify.rs +++ b/backend/src/notify.rs @@ -129,6 +129,142 @@ async fn any_drop( Ok(()) } +// ---- Subscriber fan-out --------------------------------------------------- + +/// Item fields needed to judge a drop, plus its list + owner for subscriber lookup. +#[derive(sqlx::FromRow)] +struct ItemFields { + title: String, + title_fetched: Option, + url: Option, + current_price: Option, + target_price: Option, + currency: Option, + in_stock: Option, + list_id: Uuid, + owner_id: Uuid, +} + +#[derive(sqlx::FromRow)] +struct Subscriber { + user_id: Uuid, + email: String, + display_name: Option, + notify_email: bool, +} + +/// Fan the same price-drop signal out to everyone subscribed to this item or to +/// its parent list (the owner is notified separately via [`maybe_notify_drop`]). +/// Best-effort: never errors the refetch. +pub async fn maybe_notify_subscribers(state: &AppState, item_id: Uuid) { + if let Err(e) = run_subs(state, item_id).await { + tracing::warn!(item = %item_id, error = %e, "subscriber notification failed"); + } +} + +async fn run_subs(state: &AppState, item_id: Uuid) -> anyhow::Result<()> { + let item: Option = sqlx::query_as( + "SELECT i.title, i.title_fetched, i.url, i.current_price, i.target_price, + i.currency, i.in_stock, i.list_id, l.user_id AS owner_id + FROM items i JOIN lists l ON l.id = i.list_id + WHERE i.id = $1", + ) + .bind(item_id) + .fetch_optional(&state.pool) + .await?; + + let Some(item) = item else { return Ok(()) }; + let Some(price) = item.current_price else { + return Ok(()); + }; + + // Distinct subscribers reached via the item directly OR its parent list; + // never the owner (they get the owner-path email). + let subs: Vec = sqlx::query_as( + "SELECT DISTINCT u.id AS user_id, u.email, u.display_name, st.notify_email + FROM users u + JOIN user_settings st ON st.user_id = u.id + JOIN subscriptions sub ON sub.user_id = u.id + WHERE (sub.item_id = $1 OR sub.list_id = $2) AND u.id <> $3", + ) + .bind(item_id) + .bind(item.list_id) + .bind(item.owner_id) + .fetch_all(&state.pool) + .await?; + + for s in subs { + let row = NotifyRow { + title: item.title.clone(), + title_fetched: item.title_fetched.clone(), + url: item.url.clone(), + current_price: item.current_price, + target_price: item.target_price, + currency: item.currency.clone(), + in_stock: item.in_stock, + notified_at: None, // subscriber latch lives in subscription_notify + email: s.email, + display_name: s.display_name, + notify_email: s.notify_email, + }; + let res = match item.target_price { + Some(target) => target_drop_sub(state, s.user_id, item_id, &row, price, target).await, + // No target → reuse the owner's any-drop logic (latch-free; reads + // only shared price_history). Each subscriber gets their own email. + None => any_drop(state, item_id, &row, price).await, + }; + if let Err(e) = res { + tracing::warn!(item = %item_id, user = %s.user_id, error = %e, "subscriber notify failed"); + } + } + Ok(()) +} + +/// Subscriber target mode: same semantics as [`target_drop`], but the de-dupe +/// latch lives per-subscriber in `subscription_notify(user_id, item_id)`. +async fn target_drop_sub( + state: &AppState, + user_id: Uuid, + item_id: Uuid, + row: &NotifyRow, + price: Decimal, + target: Decimal, +) -> anyhow::Result<()> { + let latched: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM subscription_notify WHERE user_id = $1 AND item_id = $2)", + ) + .bind(user_id) + .bind(item_id) + .fetch_one(&state.pool) + .await?; + + let on_sale = price <= target; + match (on_sale, latched) { + (true, false) => { + if row.notify_email { + send(state, row, price, Some(target)).await?; + } + sqlx::query( + "INSERT INTO subscription_notify (user_id, item_id) VALUES ($1, $2) + ON CONFLICT (user_id, item_id) DO UPDATE SET notified_at = now()", + ) + .bind(user_id) + .bind(item_id) + .execute(&state.pool) + .await?; + } + (false, true) => { + sqlx::query("DELETE FROM subscription_notify WHERE user_id = $1 AND item_id = $2") + .bind(user_id) + .bind(item_id) + .execute(&state.pool) + .await?; + } + _ => {} + } + Ok(()) +} + async fn send( state: &AppState, row: &NotifyRow, diff --git a/backend/src/routes/lists.rs b/backend/src/routes/lists.rs index 7a6687b..13fa825 100644 --- a/backend/src/routes/lists.rs +++ b/backend/src/routes/lists.rs @@ -19,6 +19,8 @@ pub fn router() -> Router { "/lists/{id}", axum::routing::patch(update_list).delete(delete_list), ) + .route("/lists/{id}/share", post(share_list).delete(unshare_list)) + .route("/shared/{token}", get(shared_view)) .route("/lists/{id}/items", get(list_items).post(create_item)) .route( "/items/{id}", @@ -39,6 +41,9 @@ const ITEM_COLS_I: &str = "i.id, i.list_id, i.title, i.url, i.note, i.status::te i.in_stock, i.source, i.fetched_at, i.track_enabled, i.last_error, i.checked_at, \ i.created_at, i.updated_at"; +const LIST_COLS: &str = + "id, user_id, name, emoji, description, share_token, position, created_at, updated_at"; + const ALLOWED_STATUS: &[&str] = &["coveted", "acquired", "renounced"]; // ---- Lists ---------------------------------------------------------------- @@ -47,10 +52,10 @@ async fn list_lists( State(state): State, AuthUser(user): AuthUser, ) -> AppResult>> { - let lists = sqlx::query_as::<_, List>( - "SELECT id, user_id, name, emoji, description, position, created_at, updated_at - FROM lists WHERE user_id = $1 ORDER BY position, created_at", - ) + let lists = sqlx::query_as::<_, List>(&format!( + "SELECT {LIST_COLS} + FROM lists WHERE user_id = $1 ORDER BY position, created_at" + )) .bind(user.id) .fetch_all(&state.pool) .await?; @@ -75,12 +80,12 @@ async fn create_list( req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; - let list = sqlx::query_as::<_, List>( + let list = sqlx::query_as::<_, List>(&format!( "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 id, user_id, name, emoji, description, position, created_at, updated_at", - ) + RETURNING {LIST_COLS}" + )) .bind(user.id) .bind(req.name.trim()) .bind(opt_trim(req.emoji)) @@ -110,15 +115,15 @@ async fn update_list( req.validate() .map_err(|e| AppError::Validation(e.to_string()))?; - let list = sqlx::query_as::<_, 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) WHERE id = $1 AND user_id = $2 - RETURNING id, user_id, name, emoji, description, position, created_at, updated_at", - ) + RETURNING {LIST_COLS}" + )) .bind(id) .bind(user.id) .bind(req.name.map(|s| s.trim().to_string())) @@ -147,6 +152,73 @@ async fn delete_list( Ok(Json(serde_json::json!({ "deleted": id }))) } +// ---- Sharing -------------------------------------------------------------- + +/// Turn on public sharing: mint a token if the list doesn't have one yet +/// (idempotent — repeat calls keep the same link). Returns the updated list. +async fn share_list( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let token = Uuid::new_v4().simple().to_string(); + 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}" + )) + .bind(id) + .bind(user.id) + .bind(token) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(list)) +} + +/// Revoke sharing: any existing link stops working immediately. +async fn unshare_list( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let list = sqlx::query_as::<_, List>(&format!( + "UPDATE lists SET share_token = NULL + WHERE id = $1 AND user_id = $2 + RETURNING {LIST_COLS}" + )) + .bind(id) + .bind(user.id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(list)) +} + +/// Public, unauthenticated read-only view of a shared list + its items. +/// No `AuthUser` extractor: holding the secret token is the only credential. +async fn shared_view( + State(state): State, + Path(token): Path, +) -> AppResult> { + let list = sqlx::query_as::<_, List>(&format!( + "SELECT {LIST_COLS} FROM lists WHERE share_token = $1" + )) + .bind(&token) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + + let items = sqlx::query_as::<_, Item>(&format!( + "SELECT {ITEM_COLS} FROM items WHERE list_id = $1 ORDER BY position, created_at" + )) + .bind(list.id) + .fetch_all(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "list": list, "items": items }))) +} + // ---- Items ---------------------------------------------------------------- /// Confirm the list exists and belongs to the user. Returns NotFound otherwise. diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 77e0c7f..e5970ac 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -11,6 +11,7 @@ use crate::models::UserSettings; use crate::state::AppState; mod lists; +mod subs; pub fn router() -> Router { Router::new() @@ -18,6 +19,7 @@ pub fn router() -> Router { .route("/settings", patch(update_settings)) .route("/profile", patch(update_profile)) .merge(lists::router()) + .merge(subs::router()) } async fn health() -> Json { diff --git a/backend/src/routes/subs.rs b/backend/src/routes/subs.rs new file mode 100644 index 0000000..39ab41b --- /dev/null +++ b/backend/src/routes/subs.rs @@ -0,0 +1,181 @@ +//! Subscriptions: a logged-in user follows another user's shared list or item +//! to receive the same price-drop emails. Subscribing requires the target to be +//! currently shared (`lists.share_token IS NOT NULL`) and not the user's own. + +use axum::extract::{Path, State}; +use axum::routing::get; +use axum::{Json, Router}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +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("/subscriptions", get(list_subscriptions).post(subscribe)) + .route("/subscriptions/{id}", axum::routing::delete(unsubscribe)) +} + +/// One subscription, enriched with the followed list/item's display fields so +/// the /subscriptions page can render without extra round-trips. +#[derive(Debug, Serialize, sqlx::FromRow)] +struct SubscriptionView { + id: Uuid, + kind: String, // "list" | "item" + #[serde(with = "time::serde::rfc3339")] + created_at: OffsetDateTime, + list_id: Option, + item_id: Option, + title: String, + emoji: Option, + share_token: Option, + url: Option, + image_url: Option, + current_price: Option, + currency: Option, + in_stock: Option, + target_price: Option, +} + +async fn list_subscriptions( + State(state): State, + AuthUser(user): AuthUser, +) -> AppResult>> { + // List subs + item subs, unioned and enriched. Explicit casts keep the two + // SELECT arms type-compatible across the UNION. + let subs = sqlx::query_as::<_, SubscriptionView>( + "SELECT sub.id, 'list' AS kind, sub.created_at, + l.id AS list_id, NULL::uuid AS item_id, + l.name AS title, l.emoji, l.share_token, + NULL::text AS url, NULL::text AS image_url, + NULL::numeric AS current_price, NULL::text AS currency, + NULL::boolean AS in_stock, NULL::numeric AS target_price + FROM subscriptions sub + JOIN lists l ON l.id = sub.list_id + WHERE sub.user_id = $1 AND sub.list_id IS NOT NULL + UNION ALL + SELECT sub.id, 'item' AS kind, sub.created_at, + l.id AS list_id, i.id AS item_id, + COALESCE(i.title_fetched, i.title) AS title, l.emoji, l.share_token, + i.url, i.image_url, + i.current_price, i.currency, + i.in_stock, i.target_price + FROM subscriptions sub + JOIN items i ON i.id = sub.item_id + JOIN lists l ON l.id = i.list_id + WHERE sub.user_id = $1 AND sub.item_id IS NOT NULL + ORDER BY created_at DESC", + ) + .bind(user.id) + .fetch_all(&state.pool) + .await?; + Ok(Json(subs)) +} + +#[derive(Debug, Deserialize)] +struct SubscribeReq { + list_id: Option, + item_id: Option, +} + +async fn subscribe( + State(state): State, + AuthUser(user): AuthUser, + Json(req): Json, +) -> AppResult> { + let id = match (req.list_id, req.item_id) { + (Some(list_id), None) => subscribe_list(&state, user.id, list_id).await?, + (None, Some(item_id)) => subscribe_item(&state, user.id, item_id).await?, + _ => { + return Err(AppError::Validation( + "provide exactly one of list_id or item_id".into(), + )) + } + }; + Ok(Json(serde_json::json!({ "id": id }))) +} + +async fn subscribe_list(state: &AppState, user_id: Uuid, list_id: Uuid) -> AppResult { + let ok = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM lists + WHERE id = $1 AND share_token IS NOT NULL AND user_id <> $2)", + ) + .bind(list_id) + .bind(user_id) + .fetch_one(&state.pool) + .await?; + if !ok { + return Err(AppError::NotFound); + } + + // Idempotent: the partial unique index guards against duplicates. + sqlx::query( + "INSERT INTO subscriptions (user_id, list_id) SELECT $1, $2 + WHERE NOT EXISTS (SELECT 1 FROM subscriptions WHERE user_id = $1 AND list_id = $2)", + ) + .bind(user_id) + .bind(list_id) + .execute(&state.pool) + .await?; + + let id = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM subscriptions WHERE user_id = $1 AND list_id = $2", + ) + .bind(user_id) + .bind(list_id) + .fetch_one(&state.pool) + .await?; + Ok(id) +} + +async fn subscribe_item(state: &AppState, user_id: Uuid, 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 IS NOT NULL AND l.user_id <> $2)", + ) + .bind(item_id) + .bind(user_id) + .fetch_one(&state.pool) + .await?; + if !ok { + return Err(AppError::NotFound); + } + + sqlx::query( + "INSERT INTO subscriptions (user_id, item_id) SELECT $1, $2 + WHERE NOT EXISTS (SELECT 1 FROM subscriptions WHERE user_id = $1 AND item_id = $2)", + ) + .bind(user_id) + .bind(item_id) + .execute(&state.pool) + .await?; + + let id = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM subscriptions WHERE user_id = $1 AND item_id = $2", + ) + .bind(user_id) + .bind(item_id) + .fetch_one(&state.pool) + .await?; + Ok(id) +} + +async fn unsubscribe( + State(state): State, + AuthUser(user): AuthUser, + Path(id): Path, +) -> AppResult> { + let res = sqlx::query("DELETE FROM subscriptions WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user.id) + .execute(&state.pool) + .await?; + if res.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(Json(serde_json::json!({ "deleted": id }))) +} diff --git a/backend/src/worker.rs b/backend/src/worker.rs index 13b67b8..c4e9628 100644 --- a/backend/src/worker.rs +++ b/backend/src/worker.rs @@ -70,6 +70,7 @@ pub async fn refetch(state: &AppState, item_id: Uuid, url: &str) -> anyhow::Resu Ok(p) => { apply_success(&state.pool, item_id, &p).await?; notify::maybe_notify_drop(state, item_id).await; + notify::maybe_notify_subscribers(state, item_id).await; Ok(()) } Err(e) => { diff --git a/frontend/src/lib/PriceChart.svelte b/frontend/src/lib/PriceChart.svelte index 4be1074..3219eee 100644 --- a/frontend/src/lib/PriceChart.svelte +++ b/frontend/src/lib/PriceChart.svelte @@ -80,6 +80,29 @@ coords.length ? coords.reduce((hi, c, i) => (c.price > coords[hi].price ? i : hi), 0) : -1 ); + // Out-of-stock spans — shade the time the item couldn't be bought. + // Each maximal run of in_stock===false becomes a band, extended halfway + // to its in-stock neighbours so single checks still read as a region. + const oosBands = $derived.by(() => { + if (!coords.length) return []; + const bands: { x: number; w: number }[] = []; + let start: number | null = null; + for (let i = 0; i <= coords.length; i++) { + const oos = i < coords.length && coords[i].in_stock === false; + if (oos && start === null) start = i; + if (!oos && start !== null) { + const end = i - 1; + const left = start > 0 ? (coords[start - 1].cx + coords[start].cx) / 2 : coords[start].cx; + const right = + end < coords.length - 1 ? (coords[end].cx + coords[end + 1].cx) / 2 : coords[end].cx; + bands.push({ x: left, w: Math.max(right - left, 3) }); + start = null; + } + } + return bands; + }); + const anyOos = $derived(coords.some((c) => c.in_stock === false)); + const targetY = $derived(target != null && stats ? y(target) : null); const latest = $derived(coords.length ? coords[coords.length - 1] : null); const onSale = $derived(latest != null && target != null && latest.price <= target); @@ -168,6 +191,18 @@ + + {#each oosBands as b} + + {/each} + {#each gridLines as g} low {fmtMoney(coords[lowIdx].price)} high {fmtMoney(coords[highIdx].price)} + {#if anyOos} + out of stock + {/if} {#if latest} diff --git a/frontend/src/lib/lists.svelte.ts b/frontend/src/lib/lists.svelte.ts index 878c44a..e1cc8f0 100644 --- a/frontend/src/lib/lists.svelte.ts +++ b/frontend/src/lib/lists.svelte.ts @@ -7,11 +7,31 @@ export type List = { name: string; emoji: string | null; description: string | null; + share_token: string | null; position: number; created_at: string; updated_at: string; }; +export type SharedView = { list: List; items: Item[] }; + +export type Subscription = { + id: string; + kind: "list" | "item"; + created_at: string; + list_id: string | null; + item_id: string | null; + title: string; + emoji: string | null; + share_token: string | null; + url: string | null; + image_url: string | null; + current_price: number | null; + currency: string | null; + in_stock: boolean | null; + target_price: number | null; +}; + export type Item = { id: string; list_id: string; @@ -64,6 +84,9 @@ export const listsApi = { update: (id: string, b: Partial & { position?: number }) => 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}`), items: (listId: string) => api.get(`/lists/${listId}/items`), addItem: (listId: string, b: NewItem) => @@ -75,6 +98,11 @@ export const listsApi = { removeItem: (id: string) => api.del<{ deleted: string }>(`/items/${id}`), refetch: (id: string) => api.post(`/items/${id}/refetch`, {}), history: (id: string) => api.get(`/items/${id}/history`), + + subscriptions: () => api.get("/subscriptions"), + subscribe: (b: { list_id?: string; item_id?: string }) => + api.post<{ id: string }>("/subscriptions", b), + unsubscribe: (id: string) => api.del<{ deleted: string }>(`/subscriptions/${id}`), }; /** Reactive store for the user's lists. */ @@ -97,6 +125,52 @@ class ListsStore { await listsApi.remove(id); this.items = this.items.filter((l) => l.id !== id); } + + /** Swap in an updated list (e.g. after share/unshare). */ + replace(list: List) { + const i = this.items.findIndex((l) => l.id === list.id); + if (i >= 0) this.items[i] = list; + } } export const lists = new ListsStore(); + +/** The current user's subscriptions, with helpers to toggle by list/item. */ +class SubsStore { + items = $state([]); + loaded = $state(false); + + async load() { + this.items = await listsApi.subscriptions(); + this.loaded = true; + } + + /** Existing subscription id for a list, or null. */ + forList(listId: string): string | null { + return this.items.find((s) => s.list_id === listId)?.id ?? null; + } + + /** Existing subscription id for an item, or null. */ + forItem(itemId: string): string | null { + return this.items.find((s) => s.item_id === itemId)?.id ?? null; + } + + async subscribeList(listId: string) { + if (this.forList(listId)) return; + await listsApi.subscribe({ list_id: listId }); + await this.load(); + } + + async subscribeItem(itemId: string) { + if (this.forItem(itemId)) return; + await listsApi.subscribe({ item_id: itemId }); + await this.load(); + } + + async unsubscribe(id: string) { + await listsApi.unsubscribe(id); + this.items = this.items.filter((s) => s.id !== id); + } +} + +export const subs = new SubsStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 5b105d2..332a239 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -46,6 +46,7 @@