added more share options and collabs
This commit is contained in:
@@ -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);
|
||||
@@ -63,3 +63,29 @@ impl FromRequestParts<AppState> 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<User>);
|
||||
|
||||
impl FromRequestParts<AppState> for OptionalUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Some(session) = parts.extensions.get::<Session>().cloned() else {
|
||||
return Ok(OptionalUser(None));
|
||||
};
|
||||
let id = session
|
||||
.get::<Uuid>(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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,17 @@ pub struct List {
|
||||
pub description: Option<String>,
|
||||
/// Unguessable secret for public read-only sharing; None = private.
|
||||
pub share_token: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<OffsetDateTime>,
|
||||
|
||||
/// Shared "crossed off"/claimed state. `claimed_at` non-null = taken.
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub claimed_at: Option<OffsetDateTime>,
|
||||
pub claimed_by_name: Option<String>,
|
||||
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
|
||||
@@ -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<AppState> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<Invite>>> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<CreateInviteReq>,
|
||||
) -> AppResult<Json<Invite>> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path((id, invite_id)): Path<(Uuid, Uuid)>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<String>,
|
||||
email: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
async fn list_collaborators(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<Collaborator>>> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path((id, target)): Path<(Uuid, Uuid)>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<String>,
|
||||
role: String,
|
||||
owner_id: Uuid,
|
||||
}
|
||||
|
||||
async fn preview_invite(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(token): Path<String>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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 })))
|
||||
}
|
||||
+246
-52
@@ -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<AppState> {
|
||||
)
|
||||
.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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<Json<Vec<List>>> {
|
||||
// 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<String>,
|
||||
position: Option<i32>,
|
||||
/// List setting: allow anonymous share-link holders to cross items off.
|
||||
allow_guest_crossoff: Option<bool>,
|
||||
}
|
||||
|
||||
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<Option<String>> {
|
||||
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<Uuid> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(list_id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<Item>>> {
|
||||
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<Json<Item>> {
|
||||
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<Uuid>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
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<Option<String>> {
|
||||
let row = sqlx::query_as::<_, (Option<String>,)>(
|
||||
"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<String>>("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<Uuid>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
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<Uuid>,
|
||||
) -> AppResult<Json<Vec<PricePoint>>> {
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
/// Set or clear an item's shared claim, returning the updated row.
|
||||
async fn set_claim(
|
||||
state: &AppState,
|
||||
item_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
name: Option<String>,
|
||||
) -> AppResult<Item> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<ClaimReq>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
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<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
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<AppState>,
|
||||
OptionalUser(user): OptionalUser,
|
||||
Path((token, item_id)): Path<(String, Uuid)>,
|
||||
Json(req): Json<ClaimReq>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
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<AppState>,
|
||||
OptionalUser(_user): OptionalUser,
|
||||
Path((token, item_id)): Path<(String, Uuid)>,
|
||||
) -> AppResult<Json<Item>> {
|
||||
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<AppState>,
|
||||
Path((token, item_id)): Path<(String, Uuid)>,
|
||||
) -> AppResult<Json<Vec<PricePoint>>> {
|
||||
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<String>) -> Option<String> {
|
||||
s.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
@@ -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<AppState> {
|
||||
.route("/settings", patch(update_settings))
|
||||
.route("/profile", patch(update_profile))
|
||||
.merge(lists::router())
|
||||
.merge(collab::router())
|
||||
.merge(subs::router())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user