teamcraft/services/
personal.rs1use chrono::{DateTime, Utc};
6use serde::Serialize;
7use sqlx::PgPool;
8use uuid::Uuid;
9
10use crate::{
11 auth::Actor,
12 domain,
13 error::{AppError, AppResult},
14 util,
15};
16
17pub const SEARCH_KEYS: [&str; 8] = [
19 "q", "type", "project", "state", "system", "class", "attr", "value",
20];
21
22pub const MAX_SAVED_SEARCHES: i64 = 50;
24
25pub async fn is_favorite(pool: &PgPool, user_id: Uuid, item_id: Uuid) -> AppResult<bool> {
26 Ok(sqlx::query_scalar::<_, bool>(
27 "SELECT EXISTS (SELECT 1 FROM favorites WHERE user_id = $1 AND item_id = $2)",
28 )
29 .bind(user_id)
30 .bind(item_id)
31 .fetch_one(pool)
32 .await?)
33}
34
35pub async fn set_favorite(pool: &PgPool, actor: &Actor, item_id: Uuid, on: bool) -> AppResult<()> {
37 let exists: bool = sqlx::query_scalar(&format!(
39 "SELECT EXISTS (SELECT 1 FROM items i WHERE i.id = $1 AND {})",
40 crate::services::access::item_visible("i", 2)
41 ))
42 .bind(item_id)
43 .bind(&actor.hidden_projects)
44 .fetch_one(pool)
45 .await?;
46 if !exists {
47 return Err(AppError::not_found("Item not found."));
48 }
49 if on {
50 sqlx::query(
51 "INSERT INTO favorites (user_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
52 )
53 .bind(actor.id)
54 .bind(item_id)
55 .execute(pool)
56 .await?;
57 } else {
58 sqlx::query("DELETE FROM favorites WHERE user_id = $1 AND item_id = $2")
59 .bind(actor.id)
60 .bind(item_id)
61 .execute(pool)
62 .await?;
63 }
64 Ok(())
65}
66
67#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
69pub struct SavedSearch {
70 pub id: Uuid,
71 pub name: String,
72 pub query: String,
74 pub created_at: DateTime<Utc>,
75}
76
77pub fn normalize_query(pairs: &[(&str, &str)]) -> String {
80 SEARCH_KEYS
81 .iter()
82 .filter_map(|key| {
83 pairs
84 .iter()
85 .find(|(k, v)| k == key && !v.trim().is_empty())
86 .map(|(_, v)| format!("{key}={}", util::pct_encode(v.trim())))
87 })
88 .collect::<Vec<_>>()
89 .join("&")
90}
91
92pub async fn saved_searches(pool: &PgPool, user_id: Uuid) -> AppResult<Vec<SavedSearch>> {
93 Ok(sqlx::query_as::<_, SavedSearch>(
94 "SELECT id, name, query, created_at FROM saved_searches WHERE user_id = $1 ORDER BY name",
95 )
96 .bind(user_id)
97 .fetch_all(pool)
98 .await?)
99}
100
101pub async fn save_search(
103 pool: &PgPool,
104 actor: &Actor,
105 name: &str,
106 pairs: &[(&str, &str)],
107) -> AppResult<SavedSearch> {
108 let name = domain::require_text("Name of the search", name, 100)?;
109 let query = normalize_query(pairs);
110 if query.is_empty() {
111 return Err(AppError::BadRequest(
112 "Set a search text or a filter before saving the search.".into(),
113 ));
114 }
115 let count: i64 =
116 sqlx::query_scalar("SELECT COUNT(*) FROM saved_searches WHERE user_id = $1 AND name <> $2")
117 .bind(actor.id)
118 .bind(&name)
119 .fetch_one(pool)
120 .await?;
121 if count >= MAX_SAVED_SEARCHES {
122 return Err(AppError::BadRequest(format!(
123 "At most {MAX_SAVED_SEARCHES} saved searches – delete one first."
124 )));
125 }
126 Ok(sqlx::query_as::<_, SavedSearch>(
127 "INSERT INTO saved_searches (id, user_id, name, query) VALUES ($1, $2, $3, $4)
128 ON CONFLICT (user_id, name) DO UPDATE SET query = EXCLUDED.query
129 RETURNING id, name, query, created_at",
130 )
131 .bind(Uuid::new_v4())
132 .bind(actor.id)
133 .bind(&name)
134 .bind(&query)
135 .fetch_one(pool)
136 .await?)
137}
138
139pub async fn delete_search(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<()> {
140 let deleted = sqlx::query("DELETE FROM saved_searches WHERE id = $1 AND user_id = $2")
141 .bind(id)
142 .bind(actor.id)
143 .execute(pool)
144 .await?
145 .rows_affected();
146 if deleted == 0 {
147 return Err(AppError::not_found("Saved search not found."));
148 }
149 Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn queries_are_normalised() {
158 assert_eq!(
159 normalize_query(&[
160 ("state", "released"),
161 ("q", " a b "),
162 ("page", "3"),
163 ("type", "")
164 ]),
165 "q=a%20b&state=released"
166 );
167 assert_eq!(normalize_query(&[("page", "2")]), "");
168 }
169}