added sharing and subscriptions

This commit is contained in:
2026-06-17 23:27:37 +02:00
parent 148e441425
commit 8a614cb1d1
16 changed files with 1019 additions and 26 deletions
+82 -10
View File
@@ -19,6 +19,8 @@ pub fn router() -> Router<AppState> {
"/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<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<Vec<List>>> {
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<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<List>> {
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<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<List>> {
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<AppState>,
Path(token): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
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.
+2
View File
@@ -11,6 +11,7 @@ use crate::models::UserSettings;
use crate::state::AppState;
mod lists;
mod subs;
pub fn router() -> Router<AppState> {
Router::new()
@@ -18,6 +19,7 @@ pub fn router() -> Router<AppState> {
.route("/settings", patch(update_settings))
.route("/profile", patch(update_profile))
.merge(lists::router())
.merge(subs::router())
}
async fn health() -> Json<Value> {
+181
View File
@@ -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<AppState> {
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<Uuid>,
item_id: Option<Uuid>,
title: String,
emoji: Option<String>,
share_token: Option<String>,
url: Option<String>,
image_url: Option<String>,
current_price: Option<Decimal>,
currency: Option<String>,
in_stock: Option<bool>,
target_price: Option<Decimal>,
}
async fn list_subscriptions(
State(state): State<AppState>,
AuthUser(user): AuthUser,
) -> AppResult<Json<Vec<SubscriptionView>>> {
// 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<Uuid>,
item_id: Option<Uuid>,
}
async fn subscribe(
State(state): State<AppState>,
AuthUser(user): AuthUser,
Json(req): Json<SubscribeReq>,
) -> AppResult<Json<serde_json::Value>> {
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<Uuid> {
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<Uuid> {
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<AppState>,
AuthUser(user): AuthUser,
Path(id): Path<Uuid>,
) -> AppResult<Json<serde_json::Value>> {
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 })))
}