Skip to main content

teamcraft/services/
folders.rs

1//! The file browser of a project group: the folders its files have
2//! (`files.folder`, the structure of the engineer's working folder) and the
3//! files in them, one folder level per request (read-only; a big group is
4//! never loaded as a whole).
5//!
6//! A file belongs to the latest revision of its item, so the browser shows
7//! the group the way a new working folder would look. Items whose latest
8//! revision has no file are listed at the top of the group, after the files,
9//! so every item of the group can be reached from the browser.
10//!
11//! # Invariants
12//!
13//! * **Permission:** a hidden group answers "not found"; nothing of it is
14//!   counted or listed.
15
16use serde::Serialize;
17use sqlx::PgPool;
18use uuid::Uuid;
19
20use crate::{
21    auth::Actor,
22    cadformat::{self, FormatKind},
23    error::{AppError, AppResult},
24    services::{access, Page},
25};
26
27/// More sub-folders than this are never returned for one folder.
28pub const MAX_FOLDERS: i64 = 1000;
29
30/// A folder directly below the listed one.
31#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
32pub struct SubFolder {
33    pub name: String,
34    /// Path inside the group (`chassis/brackets`), the `folder` to ask for.
35    #[sqlx(skip)]
36    pub path: String,
37    /// Files in it and in the folders below it.
38    pub files: i64,
39}
40
41/// A file of the listed folder with the item it belongs to – or, at the top
42/// of the group, an item without files (`file_id` and `filename` are null).
43#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
44pub struct Entry {
45    pub file_id: Option<Uuid>,
46    pub filename: Option<String>,
47    pub size_bytes: Option<i64>,
48    /// Format id (see `cadformat`), `""` if unknown or without a file.
49    pub format: String,
50    /// Label of the format ("SolidWorks assembly"); null if unknown.
51    #[sqlx(skip)]
52    pub format_label: Option<&'static str>,
53    /// What the file contains (`part`, `assembly`, `drawing`, …); null if unknown.
54    #[sqlx(skip)]
55    pub format_kind: Option<FormatKind>,
56    pub item_number: String,
57    pub item_type: String,
58    /// Name, code and state of the item's latest revision.
59    pub name: String,
60    pub code: String,
61    pub state: String,
62    pub checked_out_by_name: Option<String>,
63}
64
65/// One folder of a group.
66#[derive(Clone, Debug, Serialize)]
67pub struct Level {
68    /// The listed folder (`""` = the top of the group).
69    pub folder: String,
70    pub folders: Vec<SubFolder>,
71    /// The requested page of the folder's entries, files first, by name.
72    pub entries: Vec<Entry>,
73    /// Entries of the folder in all (0 beyond the last page).
74    pub total: i64,
75}
76
77/// The latest revision of the item aliased `i`.
78const LATEST: &str = "CROSS JOIN LATERAL (SELECT r.id, r.code, r.state, r.name, r.checked_out_by
79                             FROM revisions r WHERE r.item_id = i.id
80                             ORDER BY r.seq DESC LIMIT 1) lr";
81
82/// The sub-folders and entries of `folder` (normalised by
83/// `domain::normalize_folder`; `""` = the top) in a project group.
84pub async fn level(
85    pool: &PgPool,
86    actor: &Actor,
87    project_id: Uuid,
88    folder: &str,
89    page: Page,
90) -> AppResult<Level> {
91    access::ensure_visible(
92        actor,
93        Some(project_id),
94        AppError::not_found("Project group not found."),
95    )?;
96    let visible = access::item_visible("i", 2);
97    // $3 = the folder; a file is below it when its folder starts with
98    // "<folder>/" (every folder is below the top). The comparison is on the
99    // prefix itself, not a LIKE pattern: folder names may contain % and _.
100    let mut folders = sqlx::query_as::<_, SubFolder>(&format!(
101        "SELECT sub.name, COUNT(*) AS files
102         FROM (SELECT split_part(CASE WHEN $3 = '' THEN f.folder
103                                      ELSE substr(f.folder, length($3) + 2) END, '/', 1) AS name
104               FROM items i {LATEST}
105               JOIN files f ON f.revision_id = lr.id
106               WHERE i.project_id = $1 AND {visible}
107                 AND CASE WHEN $3 = '' THEN f.folder <> ''
108                          ELSE left(f.folder, length($3) + 1) = $3 || '/' END) sub
109         GROUP BY sub.name
110         ORDER BY lower(sub.name), sub.name
111         LIMIT {MAX_FOLDERS}"
112    ))
113    .bind(project_id)
114    .bind(&actor.hidden_projects)
115    .bind(folder)
116    .fetch_all(pool)
117    .await?;
118    for sub in &mut folders {
119        sub.path = if folder.is_empty() {
120            sub.name.clone()
121        } else {
122            format!("{folder}/{}", sub.name)
123        };
124    }
125
126    #[derive(sqlx::FromRow)]
127    struct Counted {
128        #[sqlx(flatten)]
129        entry: Entry,
130        total: i64,
131    }
132    let rows = sqlx::query_as::<_, Counted>(&format!(
133        "SELECT e.*, COUNT(*) OVER () AS total
134         FROM (SELECT f.id AS file_id, f.filename, f.size_bytes, COALESCE(f.format, '') AS format,
135                      i.number AS item_number, i.item_type, lr.name, lr.code, lr.state,
136                      u.display_name AS checked_out_by_name
137               FROM items i {LATEST}
138               JOIN files f ON f.revision_id = lr.id
139               LEFT JOIN users u ON u.id = lr.checked_out_by
140               WHERE i.project_id = $1 AND {visible} AND f.folder = $3
141               UNION ALL
142               SELECT NULL::uuid, NULL::text, NULL::bigint, '',
143                      i.number, i.item_type, lr.name, lr.code, lr.state, u.display_name
144               FROM items i {LATEST}
145               LEFT JOIN users u ON u.id = lr.checked_out_by
146               WHERE i.project_id = $1 AND {visible} AND $3 = ''
147                 AND NOT EXISTS (SELECT 1 FROM files f WHERE f.revision_id = lr.id)) e
148         ORDER BY (e.filename IS NULL), lower(COALESCE(e.filename, e.name)), e.filename,
149                  e.item_number
150         LIMIT $4 OFFSET $5"
151    ))
152    .bind(project_id)
153    .bind(&actor.hidden_projects)
154    .bind(folder)
155    .bind(page.limit)
156    .bind(page.offset)
157    .fetch_all(pool)
158    .await?;
159    let total = rows.first().map(|r| r.total).unwrap_or(0);
160    let entries = rows
161        .into_iter()
162        .map(|r| {
163            let mut entry = r.entry;
164            if let Some(info) = cadformat::info(&entry.format) {
165                entry.format_label = Some(info.label);
166                entry.format_kind = Some(info.kind);
167            }
168            entry
169        })
170        .collect();
171    Ok(Level {
172        folder: folder.to_string(),
173        folders,
174        entries,
175        total,
176    })
177}