Skip to main content

teamcraft/services/
personal.rs

1//! Personal working sets: favorites (Teamcenter "Favorites") and saved item
2//! searches (Teamcenter "Saved Queries").
3//! Both belong to one user only, so they are not audited.
4
5use 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
17/// Keys of the item search that a saved search keeps, in this order.
18pub const SEARCH_KEYS: [&str; 8] = [
19    "q", "type", "project", "state", "system", "class", "attr", "value",
20];
21
22/// Most saved searches per user.
23pub 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
35/// Adds the item to the actor's favorites (`on`) or removes it.
36pub async fn set_favorite(pool: &PgPool, actor: &Actor, item_id: Uuid, on: bool) -> AppResult<()> {
37    // Hidden items give the same "not found" as missing ones.
38    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/// A saved item search.
68#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
69pub struct SavedSearch {
70    pub id: Uuid,
71    pub name: String,
72    /// Query string of `/items` (`q=bracket&state=released`).
73    pub query: String,
74    pub created_at: DateTime<Utc>,
75}
76
77/// The normalised query string of a search: only [`SEARCH_KEYS`], empty
78/// values dropped, values percent-encoded.
79pub 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
101/// Saves (or, with an existing name, replaces) a search of the actor.
102pub 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}