Skip to main content

teamcraft/services/
explorer.rs

1//! The vault explorer: items as a folder browser (project group → items →
2//! files), the way engineering vaults are usually worked with – every row
3//! shows the lock, and check-out / check-in / cancel are one click away.
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    services::access,
15};
16
17/// Rows shown at most in one folder (the search narrows larger folders).
18pub const LIMIT: i64 = 500;
19
20/// One item of a folder, with its latest revision and its lock.
21#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
22pub struct Entry {
23    pub item_id: Uuid,
24    pub number: String,
25    pub item_type: String,
26    pub project_id: Option<Uuid>,
27    pub project_code: Option<String>,
28    pub revision_id: Uuid,
29    pub code: String,
30    pub name: String,
31    pub state: String,
32    pub checked_out_by: Option<Uuid>,
33    pub checked_out_by_name: Option<String>,
34    pub checked_out_at: Option<DateTime<Utc>>,
35    pub updated_at: DateTime<Utc>,
36    pub file_count: i64,
37}
38
39/// A folder of the tree: a project group with its item and lock counts.
40#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
41pub struct Folder {
42    pub id: Uuid,
43    pub code: String,
44    pub name: String,
45    pub items: i64,
46    pub locked: i64,
47}
48
49/// The folders (active project groups the actor may see) with counts, in
50/// one query. The check-outs are counted once for all groups (not per
51/// group: that read every checked-out revision once per group).
52pub async fn folders(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Folder>> {
53    Ok(sqlx::query_as::<_, Folder>(&format!(
54        "WITH locked AS (
55             SELECT i.project_id, COUNT(*) AS locked
56             FROM revisions r JOIN items i ON i.id = r.item_id
57             WHERE r.checked_out_by IS NOT NULL
58             GROUP BY i.project_id
59         )
60         SELECT p.id, p.code, p.name,
61                (SELECT COUNT(*) FROM items i WHERE i.project_id = p.id) AS items,
62                COALESCE(l.locked, 0) AS locked
63         FROM projects p LEFT JOIN locked l ON l.project_id = p.id
64         WHERE p.active AND {} ORDER BY p.code",
65        access::project_visible("p.id", 1)
66    ))
67    .bind(&actor.hidden_projects)
68    .fetch_all(pool)
69    .await?)
70}
71
72/// What the middle pane lists.
73pub enum Listing<'a> {
74    /// Items of a project group (latest revision each).
75    Project(Uuid),
76    /// Every revision checked out by this user.
77    CheckedOutBy(Uuid),
78    /// Every checked-out revision.
79    AllCheckedOut,
80    /// Items without project group.
81    Unassigned,
82    /// Search over all items (number or name).
83    Search(&'a str),
84    /// The favorites of this user (latest revision each).
85    Favorites(Uuid),
86}
87
88const SELECT: &str =
89    "SELECT i.id AS item_id, i.number, i.item_type, i.project_id, p.code AS project_code,
90        r.id AS revision_id, r.code, r.name, r.state, r.checked_out_by,
91        u.display_name AS checked_out_by_name, r.checked_out_at, r.updated_at,
92        (SELECT COUNT(*) FROM files f WHERE f.revision_id = r.id) AS file_count
93    FROM items i
94    LEFT JOIN projects p ON p.id = i.project_id";
95
96/// Entries of a folder the actor may see, filtered by `query` (number or
97/// name, may be empty). Every listing shows only items the actor may read;
98/// a hidden project group answers "not found".
99pub async fn list(
100    pool: &PgPool,
101    actor: &Actor,
102    listing: Listing<'_>,
103    query: &str,
104) -> AppResult<Vec<Entry>> {
105    if let Listing::Project(id) = listing {
106        // unknown and hidden groups answer alike (no existence oracle)
107        let exists: bool =
108            sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM projects WHERE id = $1)")
109                .bind(id)
110                .fetch_one(pool)
111                .await?;
112        if !exists {
113            return Err(AppError::not_found("Folder not found."));
114        }
115        access::ensure_visible(actor, Some(id), AppError::not_found("Folder not found."))?;
116    }
117    let pattern = if query.trim().is_empty() {
118        String::new()
119    } else {
120        domain::like_pattern(query)
121    };
122    let latest = "JOIN LATERAL (SELECT * FROM revisions r WHERE r.item_id = i.id
123                                ORDER BY r.seq DESC LIMIT 1) r ON true";
124    let (sql, id) = match listing {
125        Listing::Project(id) => (
126            format!("{SELECT} {latest} LEFT JOIN users u ON u.id = r.checked_out_by WHERE i.project_id = $1"),
127            Some(id),
128        ),
129        Listing::Unassigned => (
130            format!("{SELECT} {latest} LEFT JOIN users u ON u.id = r.checked_out_by WHERE i.project_id IS NULL AND $1::uuid IS NULL"),
131            None,
132        ),
133        Listing::Search(_) => (
134            format!("{SELECT} {latest} LEFT JOIN users u ON u.id = r.checked_out_by WHERE $1::uuid IS NULL"),
135            None,
136        ),
137        Listing::CheckedOutBy(user) => (
138            format!("{SELECT} JOIN revisions r ON r.item_id = i.id LEFT JOIN users u ON u.id = r.checked_out_by
139                     WHERE r.checked_out_by = $1"),
140            Some(user),
141        ),
142        Listing::Favorites(user) => (
143            format!("{SELECT} {latest} LEFT JOIN users u ON u.id = r.checked_out_by
144                     WHERE i.id IN (SELECT item_id FROM favorites WHERE user_id = $1)"),
145            Some(user),
146        ),
147        Listing::AllCheckedOut => (
148            format!("{SELECT} JOIN revisions r ON r.item_id = i.id LEFT JOIN users u ON u.id = r.checked_out_by
149                     WHERE r.checked_out_by IS NOT NULL AND $1::uuid IS NULL"),
150            None,
151        ),
152    };
153    let sql = format!(
154        "{sql} AND ($2 = '' OR i.number ILIKE $2 OR r.name ILIKE $2) AND {}
155         ORDER BY i.number LIMIT {LIMIT}",
156        access::item_visible("i", 3)
157    );
158    Ok(sqlx::query_as::<_, Entry>(&sql)
159        .bind(id)
160        .bind(&pattern)
161        .bind(&actor.hidden_projects)
162        .fetch_all(pool)
163        .await?)
164}