favourited notes
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
||||
* Source-Available Code / Dual-Licensed.
|
||||
*
|
||||
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
||||
* Commercial or production use requires a valid paid license.
|
||||
* See LICENSE file in repository root for details.
|
||||
*/
|
||||
|
||||
use super::*;
|
||||
use sqlx::{Row, any::AnyRow};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FavoriteTarget {
|
||||
kind: String,
|
||||
slug: String,
|
||||
#[serde(default)]
|
||||
workspace_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FavoriteStatusQuery {
|
||||
kind: String,
|
||||
slug: String,
|
||||
#[serde(default)]
|
||||
workspace_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FavoriteListQuery {
|
||||
#[serde(default)]
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FavoriteStatus {
|
||||
favorite: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FavoriteList {
|
||||
items: Vec<FavoriteItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FavoriteItem {
|
||||
kind: String,
|
||||
slug: String,
|
||||
workspace_slug: Option<String>,
|
||||
workspace_title: Option<String>,
|
||||
title: String,
|
||||
url: String,
|
||||
updated_at: String,
|
||||
favorited_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FavoritePadRow {
|
||||
slug: String,
|
||||
title: String,
|
||||
updated_at: String,
|
||||
private_resource: i64,
|
||||
protected_resource: i64,
|
||||
favorited_at: String,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for FavoritePadRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
slug: crate::row_decode::text(row, "slug")?,
|
||||
title: crate::row_decode::text(row, "title")?,
|
||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||
private_resource: row.try_get("private")?,
|
||||
protected_resource: row.try_get("protected")?,
|
||||
favorited_at: crate::row_decode::text(row, "favorited_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct FavoriteNoteRow {
|
||||
slug: String,
|
||||
title: String,
|
||||
updated_at: String,
|
||||
workspace_slug: String,
|
||||
workspace_title: String,
|
||||
private_resource: i64,
|
||||
protected_resource: i64,
|
||||
favorited_at: String,
|
||||
}
|
||||
|
||||
impl<'r> sqlx::FromRow<'r, AnyRow> for FavoriteNoteRow {
|
||||
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
||||
Ok(Self {
|
||||
slug: crate::row_decode::text(row, "slug")?,
|
||||
title: crate::row_decode::text(row, "title")?,
|
||||
updated_at: crate::row_decode::text(row, "updated_at")?,
|
||||
workspace_slug: crate::row_decode::text(row, "workspace_slug")?,
|
||||
workspace_title: crate::row_decode::text(row, "workspace_title")?,
|
||||
private_resource: row.try_get("private")?,
|
||||
protected_resource: row.try_get("protected")?,
|
||||
favorited_at: crate::row_decode::text(row, "favorited_at")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FavoriteResource {
|
||||
Pad(i64),
|
||||
Note(i64),
|
||||
}
|
||||
|
||||
async fn favorite_user(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<crate::auth::User, ApiError> {
|
||||
session_user(state, headers)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::forbidden("Log in to use favorites"))
|
||||
}
|
||||
|
||||
async fn resolve_favorite_target(
|
||||
state: &SharedState,
|
||||
headers: &HeaderMap,
|
||||
target: &FavoriteTarget,
|
||||
) -> Result<FavoriteResource, ApiError> {
|
||||
let kind = target.kind.trim();
|
||||
let slug = target.slug.trim();
|
||||
if slug.is_empty() {
|
||||
return Err(ApiError::bad_request("Favorite slug is required"));
|
||||
}
|
||||
|
||||
match kind {
|
||||
"pad" => {
|
||||
let pad = db::find_pad(&state.db, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
if (pad.is_private != 0 || pad.password_hash.is_some())
|
||||
&& !has_header_resource_access(state, headers, "pad", &pad.slug).await?
|
||||
{
|
||||
return Err(ApiError::not_found_note());
|
||||
}
|
||||
Ok(FavoriteResource::Pad(pad.id))
|
||||
}
|
||||
"note" => {
|
||||
let workspace_slug = target
|
||||
.workspace_slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| ApiError::bad_request("Workspace slug is required for a note"))?;
|
||||
let workspace = db::find_workspace(&state.db, workspace_slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_workspace)?;
|
||||
if (workspace.is_private != 0 || workspace.password_hash.is_some())
|
||||
&& !has_header_resource_access(state, headers, "workspace", &workspace.slug).await?
|
||||
{
|
||||
return Err(ApiError::not_found_workspace());
|
||||
}
|
||||
let note = db::find_note(&state.db, workspace.id, slug)
|
||||
.await?
|
||||
.ok_or_else(ApiError::not_found_note)?;
|
||||
Ok(FavoriteResource::Note(note.id))
|
||||
}
|
||||
_ => Err(ApiError::bad_request("Invalid favorite resource kind")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn favorite_exists(
|
||||
state: &SharedState,
|
||||
user_id: i64,
|
||||
resource: FavoriteResource,
|
||||
) -> Result<bool, ApiError> {
|
||||
let (query, resource_id) = match resource {
|
||||
FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_EXISTS, id),
|
||||
FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_EXISTS, id),
|
||||
};
|
||||
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query))
|
||||
.bind(user_id)
|
||||
.bind(resource_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn favorite_status(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<FavoriteStatusQuery>,
|
||||
) -> Result<Json<FavoriteStatus>, ApiError> {
|
||||
let user = favorite_user(&state, &headers).await?;
|
||||
let target = FavoriteTarget {
|
||||
kind: query.kind,
|
||||
slug: query.slug,
|
||||
workspace_slug: query.workspace_slug,
|
||||
};
|
||||
let resource = resolve_favorite_target(&state, &headers, &target).await?;
|
||||
Ok(Json(FavoriteStatus {
|
||||
favorite: favorite_exists(&state, user.id, resource).await?,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn add_favorite(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(target): Json<FavoriteTarget>,
|
||||
) -> Result<Json<FavoriteStatus>, ApiError> {
|
||||
let user = favorite_user(&state, &headers).await?;
|
||||
let resource = resolve_favorite_target(&state, &headers, &target).await?;
|
||||
let (query, resource_id) = match resource {
|
||||
FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_INSERT, id),
|
||||
FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_INSERT, id),
|
||||
};
|
||||
sqlx::query(queries::get(state.db.kind(), query))
|
||||
.bind(user.id)
|
||||
.bind(resource_id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(FavoriteStatus { favorite: true }))
|
||||
}
|
||||
|
||||
pub async fn remove_favorite(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(target): Json<FavoriteTarget>,
|
||||
) -> Result<Json<FavoriteStatus>, ApiError> {
|
||||
let user = favorite_user(&state, &headers).await?;
|
||||
let resource = resolve_favorite_target(&state, &headers, &target).await?;
|
||||
let (query, resource_id) = match resource {
|
||||
FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_DELETE, id),
|
||||
FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_DELETE, id),
|
||||
};
|
||||
sqlx::query(queries::get(state.db.kind(), query))
|
||||
.bind(user.id)
|
||||
.bind(resource_id)
|
||||
.execute(state.db.pool())
|
||||
.await?;
|
||||
Ok(Json(FavoriteStatus { favorite: false }))
|
||||
}
|
||||
|
||||
pub async fn list_favorites(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<FavoriteListQuery>,
|
||||
) -> Result<Json<FavoriteList>, ApiError> {
|
||||
let user = favorite_user(&state, &headers).await?;
|
||||
let pads = sqlx::query_as::<_, FavoritePadRow>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::USER_FAVORITE_PAD_LIST,
|
||||
))
|
||||
.bind(user.id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
let notes = sqlx::query_as::<_, FavoriteNoteRow>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::USER_FAVORITE_NOTE_LIST,
|
||||
))
|
||||
.bind(user.id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?;
|
||||
|
||||
let search = query.q.trim().to_lowercase();
|
||||
let mut items = Vec::with_capacity(pads.len() + notes.len());
|
||||
|
||||
for pad in pads {
|
||||
if (pad.private_resource != 0 || pad.protected_resource != 0)
|
||||
&& !has_header_resource_access(&state, &headers, "pad", &pad.slug).await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !search.is_empty()
|
||||
&& !pad.title.to_lowercase().contains(&search)
|
||||
&& !pad.slug.to_lowercase().contains(&search)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.push(FavoriteItem {
|
||||
kind: "pad".into(),
|
||||
url: format!("/p/{}", pad.slug),
|
||||
slug: pad.slug,
|
||||
workspace_slug: None,
|
||||
workspace_title: None,
|
||||
title: pad.title,
|
||||
updated_at: db::normalize_timestamp(&pad.updated_at),
|
||||
favorited_at: db::normalize_timestamp(&pad.favorited_at),
|
||||
});
|
||||
}
|
||||
|
||||
for note in notes {
|
||||
if (note.private_resource != 0 || note.protected_resource != 0)
|
||||
&& !has_header_resource_access(
|
||||
&state,
|
||||
&headers,
|
||||
"workspace",
|
||||
¬e.workspace_slug,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !search.is_empty()
|
||||
&& !note.title.to_lowercase().contains(&search)
|
||||
&& !note.slug.to_lowercase().contains(&search)
|
||||
&& !note.workspace_title.to_lowercase().contains(&search)
|
||||
&& !note.workspace_slug.to_lowercase().contains(&search)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.push(FavoriteItem {
|
||||
kind: "note".into(),
|
||||
url: format!("/w/{}/n/{}", note.workspace_slug, note.slug),
|
||||
slug: note.slug,
|
||||
workspace_slug: Some(note.workspace_slug),
|
||||
workspace_title: Some(note.workspace_title),
|
||||
title: note.title,
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
favorited_at: db::normalize_timestamp(¬e.favorited_at),
|
||||
});
|
||||
}
|
||||
|
||||
items.sort_by(|left, right| right.favorited_at.cmp(&left.favorited_at));
|
||||
Ok(Json(FavoriteList { items }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{database::Database, state::AppState, storage::Storage};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn test_state() -> SharedState {
|
||||
let db = Database::connect("sqlite::memory:", 1)
|
||||
.await
|
||||
.expect("test database");
|
||||
crate::run_migrations(&db).await.expect("test migrations");
|
||||
Arc::new(AppState::new(
|
||||
db,
|
||||
"test".into(),
|
||||
Storage::Local {
|
||||
root: std::env::temp_dir().join("rustpad-favorite-tests"),
|
||||
},
|
||||
1_000_000,
|
||||
true,
|
||||
1_000_000,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
"error".into(),
|
||||
7,
|
||||
7,
|
||||
7,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
async fn logged_in_headers(state: &SharedState, suffix: &str) -> (HeaderMap, i64) {
|
||||
let nickname = format!("Favorite {suffix}");
|
||||
let nickname_key = nickname.to_lowercase();
|
||||
let email = format!("favorite-{suffix}@example.test");
|
||||
sqlx::query(
|
||||
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&nickname)
|
||||
.bind(&nickname_key)
|
||||
.bind(&email)
|
||||
.bind(&email)
|
||||
.bind("password-hash")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE email_key = ?")
|
||||
.bind(&email)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let token = format!("favorite-session-{suffix}");
|
||||
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
||||
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)")
|
||||
.bind(&token)
|
||||
.bind(user_id)
|
||||
.bind(expires_at)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
HeaderValue::from_str(&format!(
|
||||
"{}={token}",
|
||||
crate::security::SESSION_COOKIE
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
(headers, user_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn favorite_pad_is_idempotent_listed_and_deleted_with_pad() {
|
||||
let state = test_state().await;
|
||||
let (headers, user_id) = logged_in_headers(&state, "pad").await;
|
||||
let pad = db::create_pad(&state.db, "favorite-pad", "Favorite Pad", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let target = || FavoriteTarget {
|
||||
kind: "pad".into(),
|
||||
slug: pad.slug.clone(),
|
||||
workspace_slug: None,
|
||||
};
|
||||
|
||||
for _ in 0..2 {
|
||||
let result = add_favorite(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Json(target()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0.favorite);
|
||||
}
|
||||
|
||||
let stored: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(pad.id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored, 1);
|
||||
|
||||
let listed = list_favorites(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.0.items.len(), 1);
|
||||
assert_eq!(listed.0.items[0].kind, "pad");
|
||||
assert_eq!(listed.0.items[0].url, "/p/favorite-pad");
|
||||
|
||||
sqlx::query("DELETE FROM pads WHERE id = ?")
|
||||
.bind(pad.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let stored_after_delete: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ?")
|
||||
.bind(user_id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored_after_delete, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_note_favorite_is_hidden_after_permission_revocation() {
|
||||
let state = test_state().await;
|
||||
let (headers, user_id) = logged_in_headers(&state, "private-note").await;
|
||||
let workspace = db::create_workspace(
|
||||
&state.db,
|
||||
"private-workspace",
|
||||
"Private Workspace",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?")
|
||||
.bind(workspace.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let note = db::create_note(
|
||||
&state.db,
|
||||
workspace.id,
|
||||
"shared-note",
|
||||
"Shared Note",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_INSERT,
|
||||
))
|
||||
.bind("workspace")
|
||||
.bind(&workspace.slug)
|
||||
.bind(user_id)
|
||||
.bind("ro")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
add_favorite(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Json(FavoriteTarget {
|
||||
kind: "note".into(),
|
||||
slug: note.slug.clone(),
|
||||
workspace_slug: Some(workspace.slug.clone()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = list_favorites(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before.0.items.len(), 1);
|
||||
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_DELETE_USER,
|
||||
))
|
||||
.bind("workspace")
|
||||
.bind(&workspace.slug)
|
||||
.bind(user_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after = list_favorites(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(after.0.items.is_empty());
|
||||
let stored: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(note.id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored, 1);
|
||||
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_PERMISSION_INSERT,
|
||||
))
|
||||
.bind("workspace")
|
||||
.bind(&workspace.slug)
|
||||
.bind(user_id)
|
||||
.bind("ro")
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let restored = list_favorites(
|
||||
State(state.clone()),
|
||||
headers,
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restored.0.items.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_note_favorite_hides_when_workspace_becomes_private_and_returns_when_public() {
|
||||
let state = test_state().await;
|
||||
let (headers, user_id) = logged_in_headers(&state, "privacy-toggle").await;
|
||||
let workspace = db::create_workspace(
|
||||
&state.db,
|
||||
"privacy-toggle-workspace",
|
||||
"Privacy Toggle Workspace",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let note = db::create_note(
|
||||
&state.db,
|
||||
workspace.id,
|
||||
"privacy-toggle-note",
|
||||
"Privacy Toggle Note",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
add_favorite(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Json(FavoriteTarget {
|
||||
kind: "note".into(),
|
||||
slug: note.slug.clone(),
|
||||
workspace_slug: Some(workspace.slug.clone()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?")
|
||||
.bind(workspace.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let hidden = list_favorites(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(hidden.0.items.is_empty());
|
||||
let stored: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(note.id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored, 1);
|
||||
|
||||
sqlx::query("UPDATE workspaces SET is_private = 0 WHERE id = ?")
|
||||
.bind(workspace.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let visible_again = list_favorites(
|
||||
State(state),
|
||||
headers,
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(visible_again.0.items.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoked_share_link_hides_favorite_but_keeps_marker() {
|
||||
let state = test_state().await;
|
||||
let (mut headers, user_id) = logged_in_headers(&state, "share-revoke").await;
|
||||
let workspace = db::create_workspace(
|
||||
&state.db,
|
||||
"share-revoke-workspace",
|
||||
"Share Revoke Workspace",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?")
|
||||
.bind(workspace.id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let note = db::create_note(
|
||||
&state.db,
|
||||
workspace.id,
|
||||
"share-revoke-note",
|
||||
"Share Revoke Note",
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let share_token = "a".repeat(64);
|
||||
let token_hash = hex::encode(Sha256::digest(share_token.as_bytes()));
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
||||
.bind(&token_hash)
|
||||
.bind(Some("favorite test".to_owned()))
|
||||
.bind("workspace")
|
||||
.bind(&workspace.slug)
|
||||
.bind("ro")
|
||||
.bind(Option::<String>::None)
|
||||
.bind(user_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(),
|
||||
);
|
||||
|
||||
add_favorite(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Json(FavoriteTarget {
|
||||
kind: "note".into(),
|
||||
slug: note.slug.clone(),
|
||||
workspace_slug: Some(workspace.slug.clone()),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let visible = list_favorites(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(visible.0.items.len(), 1);
|
||||
|
||||
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.bind(&token_hash)
|
||||
.bind("workspace")
|
||||
.bind(&workspace.slug)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let hidden = list_favorites(
|
||||
State(state.clone()),
|
||||
headers,
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(hidden.0.items.is_empty());
|
||||
let stored: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(note.id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoked_password_token_hides_protected_pad_favorite() {
|
||||
let state = test_state().await;
|
||||
let (mut headers, user_id) = logged_in_headers(&state, "password-revoke").await;
|
||||
let pad = db::create_pad(
|
||||
&state.db,
|
||||
"password-revoke-pad",
|
||||
"Password Revoke Pad",
|
||||
Some("password123"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let access_token = "b".repeat(64);
|
||||
let token_hash = hex::encode(Sha256::digest(access_token.as_bytes()));
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_INSERT,
|
||||
))
|
||||
.bind(token_hash)
|
||||
.bind("pad")
|
||||
.bind(&pad.slug)
|
||||
.bind((Utc::now() + Duration::days(7)).to_rfc3339())
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(),
|
||||
);
|
||||
|
||||
add_favorite(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Json(FavoriteTarget {
|
||||
kind: "pad".into(),
|
||||
slug: pad.slug.clone(),
|
||||
workspace_slug: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let visible = list_favorites(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(visible.0.items.len(), 1);
|
||||
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
|
||||
))
|
||||
.bind("pad")
|
||||
.bind(&pad.slug)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let hidden = list_favorites(
|
||||
State(state.clone()),
|
||||
headers,
|
||||
Query(FavoriteListQuery { q: String::new() }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(hidden.0.items.is_empty());
|
||||
let stored: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(pad.id)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn favorites_require_an_active_login() {
|
||||
let state = test_state().await;
|
||||
db::create_pad(&state.db, "login-only-pad", "Login only", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let target = || FavoriteTarget {
|
||||
kind: "pad".into(),
|
||||
slug: "login-only-pad".into(),
|
||||
workspace_slug: None,
|
||||
};
|
||||
|
||||
let anonymous = add_favorite(
|
||||
State(state.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(target()),
|
||||
)
|
||||
.await
|
||||
.expect_err("anonymous favorite must fail");
|
||||
assert_eq!(anonymous.into_response().status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let (inactive_headers, user_id) = logged_in_headers(&state, "inactive").await;
|
||||
sqlx::query("UPDATE users SET is_active = 0 WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let inactive = add_favorite(
|
||||
State(state),
|
||||
inactive_headers,
|
||||
Json(target()),
|
||||
)
|
||||
.await
|
||||
.expect_err("inactive account favorite must fail");
|
||||
assert_eq!(inactive.into_response().status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_protected_pad_requires_current_resource_access() {
|
||||
let state = test_state().await;
|
||||
let (headers, _) = logged_in_headers(&state, "protected").await;
|
||||
db::create_pad(
|
||||
&state.db,
|
||||
"protected-pad",
|
||||
"Protected Pad",
|
||||
Some("password123"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = add_favorite(
|
||||
State(state),
|
||||
headers,
|
||||
Json(FavoriteTarget {
|
||||
kind: "pad".into(),
|
||||
slug: "protected-pad".into(),
|
||||
workspace_slug: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("protected favorite without resource access must fail");
|
||||
assert_eq!(error.into_response().status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,13 @@
|
||||
|
||||
mod access_tokens;
|
||||
mod error;
|
||||
mod favorites;
|
||||
mod files;
|
||||
mod pads_public;
|
||||
|
||||
pub use access_tokens::*;
|
||||
pub use error::ApiError;
|
||||
pub use favorites::*;
|
||||
pub use files::*;
|
||||
pub use pads_public::*;
|
||||
|
||||
@@ -419,6 +421,7 @@ pub struct NoteListItem {
|
||||
url: String,
|
||||
protected: bool,
|
||||
can_delete: bool,
|
||||
favorite: bool,
|
||||
created_by: Option<String>,
|
||||
participant_count: i64,
|
||||
file_count: i64,
|
||||
@@ -794,6 +797,20 @@ pub async fn open_workspace(
|
||||
let requester_user = session_user(&state, &headers).await?;
|
||||
let requester_nickname = requester_user.as_ref().map(|user| user.nickname.as_str());
|
||||
let requester_guest = requester_guest_id(&headers);
|
||||
let favorite_note_ids = if let Some(user) = requester_user.as_ref() {
|
||||
sqlx::query_scalar::<_, i64>(queries::get(
|
||||
state.db.kind(),
|
||||
queries::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
|
||||
))
|
||||
.bind(user.id)
|
||||
.bind(workspace.id)
|
||||
.fetch_all(state.db.pool())
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
|
||||
let stats = db::list_note_stats(&state.db, workspace.id)
|
||||
.await?
|
||||
@@ -838,6 +855,7 @@ pub async fn open_workspace(
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
protected: note.protected,
|
||||
can_delete,
|
||||
favorite: favorite_note_ids.contains(¬e.id),
|
||||
created_by: note.created_by,
|
||||
participant_count: stats.map_or(0, |value| value.participant_count),
|
||||
file_count: stats.map_or(0, |value| value.file_count),
|
||||
@@ -968,6 +986,7 @@ pub async fn create_note(
|
||||
updated_at: db::normalize_timestamp(¬e.updated_at),
|
||||
protected: note.protected,
|
||||
can_delete: true,
|
||||
favorite: false,
|
||||
created_by: note.created_by,
|
||||
participant_count: 0,
|
||||
file_count: 0,
|
||||
|
||||
@@ -137,6 +137,13 @@ pub fn router(
|
||||
.put(auth::update_share_link)
|
||||
.delete(auth::revoke_share_link),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/favorites",
|
||||
get(api::list_favorites)
|
||||
.put(api::add_favorite)
|
||||
.delete(api::remove_favorite),
|
||||
)
|
||||
.route("/api/auth/favorites/status", get(api::favorite_status))
|
||||
.route(
|
||||
"/share-invitations/{token}/accept",
|
||||
get(home).post(auth::accept_share_invitation),
|
||||
|
||||
@@ -1135,6 +1135,22 @@ pub async fn confirm_account_action(
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::USER_FAVORITE_PADS_DELETE_BY_USER,
|
||||
))
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::USER_FAVORITE_NOTES_DELETE_BY_USER,
|
||||
))
|
||||
.bind(user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
sqlx::query(queries::get(state.db.kind(), queries::AUTH_ANONYMIZE_USER))
|
||||
.bind(&deleted_nickname)
|
||||
.bind(normalize(&deleted_nickname))
|
||||
|
||||
@@ -80,6 +80,17 @@ pub enum Query {
|
||||
USER_ATTACH_PAD,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
USER_FAVORITE_PAD_INSERT,
|
||||
USER_FAVORITE_PAD_DELETE,
|
||||
USER_FAVORITE_PAD_EXISTS,
|
||||
USER_FAVORITE_PAD_LIST,
|
||||
USER_FAVORITE_NOTE_INSERT,
|
||||
USER_FAVORITE_NOTE_DELETE,
|
||||
USER_FAVORITE_NOTE_EXISTS,
|
||||
USER_FAVORITE_NOTE_LIST,
|
||||
USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
|
||||
USER_FAVORITE_PADS_DELETE_BY_USER,
|
||||
USER_FAVORITE_NOTES_DELETE_BY_USER,
|
||||
USER_OWNS_WORKSPACE,
|
||||
USER_OWNS_PAD,
|
||||
USER_DELETE_WORKSPACE,
|
||||
@@ -246,6 +257,17 @@ pub const USER_ATTACH_WORKSPACE: Query = Query::USER_ATTACH_WORKSPACE;
|
||||
pub const USER_ATTACH_PAD: Query = Query::USER_ATTACH_PAD;
|
||||
pub const USER_LIST_WORKSPACES: Query = Query::USER_LIST_WORKSPACES;
|
||||
pub const USER_LIST_PADS: Query = Query::USER_LIST_PADS;
|
||||
pub const USER_FAVORITE_PAD_INSERT: Query = Query::USER_FAVORITE_PAD_INSERT;
|
||||
pub const USER_FAVORITE_PAD_DELETE: Query = Query::USER_FAVORITE_PAD_DELETE;
|
||||
pub const USER_FAVORITE_PAD_EXISTS: Query = Query::USER_FAVORITE_PAD_EXISTS;
|
||||
pub const USER_FAVORITE_PAD_LIST: Query = Query::USER_FAVORITE_PAD_LIST;
|
||||
pub const USER_FAVORITE_NOTE_INSERT: Query = Query::USER_FAVORITE_NOTE_INSERT;
|
||||
pub const USER_FAVORITE_NOTE_DELETE: Query = Query::USER_FAVORITE_NOTE_DELETE;
|
||||
pub const USER_FAVORITE_NOTE_EXISTS: Query = Query::USER_FAVORITE_NOTE_EXISTS;
|
||||
pub const USER_FAVORITE_NOTE_LIST: Query = Query::USER_FAVORITE_NOTE_LIST;
|
||||
pub const USER_FAVORITE_NOTE_IDS_BY_WORKSPACE: Query = Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE;
|
||||
pub const USER_FAVORITE_PADS_DELETE_BY_USER: Query = Query::USER_FAVORITE_PADS_DELETE_BY_USER;
|
||||
pub const USER_FAVORITE_NOTES_DELETE_BY_USER: Query = Query::USER_FAVORITE_NOTES_DELETE_BY_USER;
|
||||
pub const USER_OWNS_WORKSPACE: Query = Query::USER_OWNS_WORKSPACE;
|
||||
pub const USER_OWNS_PAD: Query = Query::USER_OWNS_PAD;
|
||||
pub const USER_DELETE_WORKSPACE: Query = Query::USER_DELETE_WORKSPACE;
|
||||
|
||||
@@ -178,6 +178,39 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::USER_LIST_PADS => {
|
||||
r#"SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(1 AS SIGNED) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED), CAST(0 AS SIGNED), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_INSERT => {
|
||||
r#"INSERT IGNORE INTO user_pad_favorites (user_id, pad_id) VALUES (?, ?)"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_DELETE => {
|
||||
r#"DELETE FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_EXISTS => {
|
||||
r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_LIST => {
|
||||
r#"SELECT p.id, p.slug, CAST(p.title AS CHAR CHARACTER SET utf8mb4) AS title, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = ? ORDER BY f.created_at DESC, p.id DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_INSERT => {
|
||||
r#"INSERT IGNORE INTO user_note_favorites (user_id, note_id) VALUES (?, ?)"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_DELETE => {
|
||||
r#"DELETE FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_EXISTS => {
|
||||
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_LIST => {
|
||||
r#"SELECT n.id, n.slug, CAST(n.title AS CHAR CHARACTER SET utf8mb4) AS title, n.updated_at, w.slug AS workspace_slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS SIGNED) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS SIGNED) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => {
|
||||
r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
|
||||
r#"DELETE FROM user_pad_favorites WHERE user_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTES_DELETE_BY_USER => {
|
||||
r#"DELETE FROM user_note_favorites WHERE user_id = ?"#
|
||||
}
|
||||
Query::USER_OWNS_WORKSPACE => {
|
||||
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#
|
||||
}
|
||||
|
||||
@@ -180,6 +180,39 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::USER_LIST_PADS => {
|
||||
r#"SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = $1 UNION SELECT p.slug, p.title, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = $2 ORDER BY updated_at DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_INSERT => {
|
||||
r#"INSERT INTO user_pad_favorites (user_id, pad_id) VALUES ($1, $2) ON CONFLICT (user_id, pad_id) DO NOTHING"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_DELETE => {
|
||||
r#"DELETE FROM user_pad_favorites WHERE user_id = $1 AND pad_id = $2"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_EXISTS => {
|
||||
r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = $1 AND pad_id = $2"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_LIST => {
|
||||
r#"SELECT p.id, p.slug, p.title, p.updated_at, CAST(CASE WHEN p.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, p.id DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_INSERT => {
|
||||
r#"INSERT INTO user_note_favorites (user_id, note_id) VALUES ($1, $2) ON CONFLICT (user_id, note_id) DO NOTHING"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_DELETE => {
|
||||
r#"DELETE FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_EXISTS => {
|
||||
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = $1 AND note_id = $2"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_LIST => {
|
||||
r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = $1 ORDER BY f.created_at DESC, n.id DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => {
|
||||
r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = $1 AND n.workspace_id = $2"#
|
||||
}
|
||||
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
|
||||
r#"DELETE FROM user_pad_favorites WHERE user_id = $1"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTES_DELETE_BY_USER => {
|
||||
r#"DELETE FROM user_note_favorites WHERE user_id = $1"#
|
||||
}
|
||||
Query::USER_OWNS_WORKSPACE => {
|
||||
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 AND w.slug = $2"#
|
||||
}
|
||||
|
||||
@@ -178,6 +178,39 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::USER_LIST_PADS => {
|
||||
r#"SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_INSERT => {
|
||||
r#"INSERT OR IGNORE INTO user_pad_favorites (user_id, pad_id) VALUES (?, ?)"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_DELETE => {
|
||||
r#"DELETE FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_EXISTS => {
|
||||
r#"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_PAD_LIST => {
|
||||
r#"SELECT p.id, p.slug, p.title, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_pad_favorites f JOIN pads p ON p.id = f.pad_id WHERE f.user_id = ? ORDER BY f.created_at DESC, p.id DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_INSERT => {
|
||||
r#"INSERT OR IGNORE INTO user_note_favorites (user_id, note_id) VALUES (?, ?)"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_DELETE => {
|
||||
r#"DELETE FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_EXISTS => {
|
||||
r#"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_LIST => {
|
||||
r#"SELECT n.id, n.slug, n.title, n.updated_at, w.slug AS workspace_slug, w.title AS workspace_title, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, f.created_at AS favorited_at FROM user_note_favorites f JOIN notes n ON n.id = f.note_id JOIN workspaces w ON w.id = n.workspace_id WHERE f.user_id = ? ORDER BY f.created_at DESC, n.id DESC"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTE_IDS_BY_WORKSPACE => {
|
||||
r#"SELECT f.note_id FROM user_note_favorites f JOIN notes n ON n.id = f.note_id WHERE f.user_id = ? AND n.workspace_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_PADS_DELETE_BY_USER => {
|
||||
r#"DELETE FROM user_pad_favorites WHERE user_id = ?"#
|
||||
}
|
||||
Query::USER_FAVORITE_NOTES_DELETE_BY_USER => {
|
||||
r#"DELETE FROM user_note_favorites WHERE user_id = ?"#
|
||||
}
|
||||
Query::USER_OWNS_WORKSPACE => {
|
||||
r#"SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"#
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ fn every_backend_has_explicit_queries() {
|
||||
Q033,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
USER_FAVORITE_PAD_INSERT,
|
||||
USER_FAVORITE_PAD_DELETE,
|
||||
USER_FAVORITE_PAD_EXISTS,
|
||||
USER_FAVORITE_PAD_LIST,
|
||||
USER_FAVORITE_NOTE_INSERT,
|
||||
USER_FAVORITE_NOTE_DELETE,
|
||||
USER_FAVORITE_NOTE_EXISTS,
|
||||
USER_FAVORITE_NOTE_LIST,
|
||||
USER_FAVORITE_NOTE_IDS_BY_WORKSPACE,
|
||||
USER_FAVORITE_PADS_DELETE_BY_USER,
|
||||
USER_FAVORITE_NOTES_DELETE_BY_USER,
|
||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
|
||||
@@ -23,6 +23,8 @@ fn boolean_projections_are_normalized_for_sqlx_any() {
|
||||
(Query::AUTH_USER_BY_SHARE_IDENTIFIER, 1),
|
||||
(Query::USER_LIST_WORKSPACES, 6),
|
||||
(Query::USER_LIST_PADS, 6),
|
||||
(Query::USER_FAVORITE_PAD_LIST, 2),
|
||||
(Query::USER_FAVORITE_NOTE_LIST, 2),
|
||||
(Query::PAD_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::NOTE_PUBLIC_PAGE_DISABLED, 1),
|
||||
(Query::Q001, 1),
|
||||
|
||||
Reference in New Issue
Block a user