teamcraft/services/requests/
queries.rs1use super::*;
4
5#[derive(Clone, Debug, Default)]
7pub struct RequestFilter {
8 pub status: Option<RequestStatus>,
9 pub open_only: bool,
11 pub requested_by: Option<Uuid>,
12 pub assignee: Option<Uuid>,
13 pub item_id: Option<Uuid>,
14}
15
16const LIST_WHERE: &str = "
17 WHERE ($1::text IS NULL OR cr.status = $1)
18 AND (NOT $2 OR cr.status IN ('new', 'in_triage', 'accepted', 'deferred'))
19 AND ($3::uuid IS NULL OR cr.requested_by = $3)
20 AND ($4::uuid IS NULL OR cr.assignee = $4)
21 AND ($5::uuid IS NULL OR EXISTS (SELECT 1 FROM change_request_items f
22 WHERE f.request_id = cr.id AND f.item_id = $5))";
23
24pub async fn list(
27 pool: &PgPool,
28 actor: &Actor,
29 filter: &RequestFilter,
30 page: Page,
31) -> AppResult<(Vec<ChangeRequest>, i64)> {
32 let status = filter.status.map(RequestStatus::as_str);
33 let rows = sqlx::query_as::<_, ChangeRequest>(&format!(
34 "{REQUEST_SELECT} {LIST_WHERE} AND {}
35 ORDER BY cr.created_at DESC LIMIT $6 OFFSET $7",
36 access::request_visible("cr", 8, 9)
37 ))
38 .bind(status)
39 .bind(filter.open_only)
40 .bind(filter.requested_by)
41 .bind(filter.assignee)
42 .bind(filter.item_id)
43 .bind(page.limit)
44 .bind(page.offset)
45 .bind(actor.id)
46 .bind(&actor.hidden_projects)
47 .fetch_all(pool)
48 .await?;
49 let total = sqlx::query_scalar::<_, i64>(&format!(
50 "SELECT COUNT(*) FROM change_requests cr {LIST_WHERE} AND {}",
51 access::request_visible("cr", 6, 7)
52 ))
53 .bind(status)
54 .bind(filter.open_only)
55 .bind(filter.requested_by)
56 .bind(filter.assignee)
57 .bind(filter.item_id)
58 .bind(actor.id)
59 .bind(&actor.hidden_projects)
60 .fetch_one(pool)
61 .await?;
62 Ok((rows, total))
63}
64
65pub(crate) async fn is_visible(
67 conn: &mut PgConnection,
68 actor: &Actor,
69 request_id: Uuid,
70) -> AppResult<bool> {
71 Ok(sqlx::query_scalar::<_, bool>(&format!(
72 "SELECT EXISTS(SELECT 1 FROM change_requests cr WHERE cr.id = $1 AND {})",
73 access::request_visible("cr", 2, 3)
74 ))
75 .bind(request_id)
76 .bind(actor.id)
77 .bind(&actor.hidden_projects)
78 .fetch_one(&mut *conn)
79 .await?)
80}
81
82async fn visible_ids(pool: &PgPool, actor: &Actor, ids: &[Uuid]) -> AppResult<Vec<Uuid>> {
84 if ids.is_empty() {
85 return Ok(Vec::new());
86 }
87 Ok(sqlx::query_scalar::<_, Uuid>(&format!(
88 "SELECT cr.id FROM change_requests cr WHERE cr.id = ANY($1) AND {}",
89 access::request_visible("cr", 2, 3)
90 ))
91 .bind(ids)
92 .bind(actor.id)
93 .bind(&actor.hidden_projects)
94 .fetch_all(pool)
95 .await?)
96}
97
98async fn only_visible(
99 pool: &PgPool,
100 actor: &Actor,
101 rows: Vec<ChangeRequest>,
102) -> AppResult<Vec<ChangeRequest>> {
103 let ids: Vec<Uuid> = rows.iter().map(|r| r.id).collect();
104 let visible = visible_ids(pool, actor, &ids).await?;
105 Ok(rows
106 .into_iter()
107 .filter(|r| visible.contains(&r.id))
108 .collect())
109}
110
111pub(crate) async fn get_unchecked(pool: &PgPool, id: Uuid) -> AppResult<ChangeRequest> {
112 sqlx::query_as::<_, ChangeRequest>(&format!("{REQUEST_SELECT} WHERE cr.id = $1"))
113 .bind(id)
114 .fetch_optional(pool)
115 .await?
116 .ok_or_else(|| AppError::not_found("Change request not found."))
117}
118
119pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ChangeRequest> {
121 let request = get_unchecked(pool, id).await?;
122 let mut conn = pool.acquire().await?;
123 if !is_visible(&mut conn, actor, id).await? {
124 return Err(AppError::not_found("Change request not found."));
125 }
126 Ok(request)
127}
128
129pub async fn get_by_number(pool: &PgPool, actor: &Actor, number: &str) -> AppResult<ChangeRequest> {
131 let number = number.trim().to_ascii_uppercase();
132 let missing = || AppError::not_found(format!("Change request '{number}' not found."));
133 let request =
134 sqlx::query_as::<_, ChangeRequest>(&format!("{REQUEST_SELECT} WHERE cr.number = $1"))
135 .bind(&number)
136 .fetch_optional(pool)
137 .await?
138 .ok_or_else(missing)?;
139 let mut conn = pool.acquire().await?;
140 if !is_visible(&mut conn, actor, request.id).await? {
141 return Err(missing());
142 }
143 Ok(request)
144}
145
146pub(super) async fn lock(
148 conn: &mut PgConnection,
149 actor: &Actor,
150 id: Uuid,
151) -> AppResult<ChangeRequest> {
152 let request = lock_unchecked(conn, id).await?;
153 if !is_visible(conn, actor, id).await? {
154 return Err(AppError::not_found("Change request not found."));
155 }
156 Ok(request)
157}
158
159pub(super) async fn lock_unchecked(conn: &mut PgConnection, id: Uuid) -> AppResult<ChangeRequest> {
161 sqlx::query_scalar::<_, Uuid>("SELECT id FROM change_requests WHERE id = $1 FOR UPDATE")
163 .bind(id)
164 .fetch_optional(&mut *conn)
165 .await?
166 .ok_or_else(|| AppError::not_found("Change request not found."))?;
167 Ok(
168 sqlx::query_as::<_, ChangeRequest>(&format!("{REQUEST_SELECT} WHERE cr.id = $1"))
169 .bind(id)
170 .fetch_one(&mut *conn)
171 .await?,
172 )
173}
174
175const ITEM_SELECT: &str = "
176 SELECT i.id AS item_id, i.number AS item_number, i.item_type, i.project_id, p.code AS project_code,
177 cri.revision_id, r.code AS revision_code, r.state AS revision_state,
178 lr.name, lr.id AS latest_revision_id, lr.code AS latest_code, lr.state AS latest_state,
179 cri.note
180 FROM change_request_items cri
181 JOIN items i ON i.id = cri.item_id
182 LEFT JOIN projects p ON p.id = i.project_id
183 LEFT JOIN revisions r ON r.id = cri.revision_id
184 CROSS JOIN LATERAL (SELECT r2.id, r2.code, r2.name, r2.state FROM revisions r2
185 WHERE r2.item_id = i.id ORDER BY r2.seq DESC LIMIT 1) lr";
186
187pub async fn items(pool: &PgPool, actor: &Actor, request_id: Uuid) -> AppResult<Vec<RequestItem>> {
190 let mut rows = items_unchecked(pool, request_id).await?;
191 for row in &mut rows {
192 if !actor.can_see_project(row.project_id) {
193 row.mask();
194 }
195 }
196 rows.sort_by_key(|r| r.restricted);
198 Ok(rows)
199}
200
201pub(crate) async fn items_unchecked(
202 pool: &PgPool,
203 request_id: Uuid,
204) -> AppResult<Vec<RequestItem>> {
205 Ok(sqlx::query_as::<_, RequestItem>(&format!(
206 "{ITEM_SELECT} WHERE cri.request_id = $1 ORDER BY i.number"
207 ))
208 .bind(request_id)
209 .fetch_all(pool)
210 .await?)
211}
212
213pub async fn comments(pool: &PgPool, request_id: Uuid) -> AppResult<Vec<RequestComment>> {
215 Ok(sqlx::query_as::<_, RequestComment>(
216 "SELECT c.id, c.author_id, u.display_name AS author_name, c.body, c.created_at
217 FROM change_request_comments c JOIN users u ON u.id = c.author_id
218 WHERE c.request_id = $1 ORDER BY c.created_at, c.id",
219 )
220 .bind(request_id)
221 .fetch_all(pool)
222 .await?)
223}
224
225const ATTACHMENT_SELECT: &str = "
226 SELECT a.id, a.request_id, a.filename, a.content_type, a.size_bytes, a.sha256,
227 a.uploaded_by, u.display_name AS uploaded_by_name, a.uploaded_at
228 FROM change_request_attachments a JOIN users u ON u.id = a.uploaded_by";
229
230pub async fn attachments(pool: &PgPool, request_id: Uuid) -> AppResult<Vec<RequestAttachment>> {
231 Ok(sqlx::query_as::<_, RequestAttachment>(&format!(
232 "{ATTACHMENT_SELECT} WHERE a.request_id = $1 ORDER BY a.uploaded_at, a.id"
233 ))
234 .bind(request_id)
235 .fetch_all(pool)
236 .await?)
237}
238
239pub(crate) async fn attachment_unchecked(pool: &PgPool, id: Uuid) -> AppResult<RequestAttachment> {
240 sqlx::query_as::<_, RequestAttachment>(&format!("{ATTACHMENT_SELECT} WHERE a.id = $1"))
241 .bind(id)
242 .fetch_optional(pool)
243 .await?
244 .ok_or_else(|| AppError::not_found("Attachment not found."))
245}
246
247pub async fn attachment(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<RequestAttachment> {
249 let att = attachment_unchecked(pool, id).await?;
250 let mut conn = pool.acquire().await?;
251 if !is_visible(&mut conn, actor, att.request_id).await? {
252 return Err(AppError::not_found("Attachment not found."));
253 }
254 Ok(att)
255}
256
257pub async fn linked_changes(
260 pool: &PgPool,
261 actor: &Actor,
262 request_id: Uuid,
263) -> AppResult<(Vec<Change>, i64)> {
264 let all = sqlx::query_as::<_, Change>(&format!(
265 "{} JOIN change_request_changes x ON x.change_id = c.id
266 WHERE x.request_id = $1 ORDER BY c.created_at",
267 changes::CHANGE_SELECT
268 ))
269 .bind(request_id)
270 .fetch_all(pool)
271 .await?;
272 let ids: Vec<Uuid> = all.iter().map(|c| c.id).collect();
273 let visible = changes::visible_ids(pool, actor, &ids).await?;
274 let hidden = (all.len() - visible.len()) as i64;
275 Ok((
276 all.into_iter()
277 .filter(|c| visible.contains(&c.id))
278 .collect(),
279 hidden,
280 ))
281}
282
283pub async fn for_change(
285 pool: &PgPool,
286 actor: &Actor,
287 change_id: Uuid,
288) -> AppResult<Vec<ChangeRequest>> {
289 let rows = sqlx::query_as::<_, ChangeRequest>(&format!(
290 "{REQUEST_SELECT} JOIN change_request_changes x ON x.request_id = cr.id
291 WHERE x.change_id = $1 ORDER BY cr.number"
292 ))
293 .bind(change_id)
294 .fetch_all(pool)
295 .await?;
296 only_visible(pool, actor, rows).await
297}
298
299pub async fn open_for_item(
301 pool: &PgPool,
302 actor: &Actor,
303 item_id: Uuid,
304) -> AppResult<Vec<ChangeRequest>> {
305 let rows = sqlx::query_as::<_, ChangeRequest>(&format!(
306 "{REQUEST_SELECT} JOIN change_request_items f ON f.request_id = cr.id
307 WHERE f.item_id = $1 AND cr.status IN {OPEN_STATES}
308 ORDER BY cr.created_at DESC"
309 ))
310 .bind(item_id)
311 .fetch_all(pool)
312 .await?;
313 only_visible(pool, actor, rows).await
314}
315
316pub async fn assigned_to(pool: &PgPool, actor: &Actor) -> AppResult<Vec<ChangeRequest>> {
318 Ok(sqlx::query_as::<_, ChangeRequest>(&format!(
319 "{REQUEST_SELECT} WHERE cr.assignee = $1 AND cr.status IN {OPEN_STATES}
320 ORDER BY cr.due_date NULLS LAST, cr.created_at"
321 ))
322 .bind(actor.id)
323 .fetch_all(pool)
324 .await?)
325}
326
327pub async fn open_by(pool: &PgPool, actor: &Actor) -> AppResult<Vec<ChangeRequest>> {
329 Ok(sqlx::query_as::<_, ChangeRequest>(&format!(
330 "{REQUEST_SELECT} WHERE cr.requested_by = $1 AND cr.status IN {OPEN_STATES}
331 ORDER BY cr.updated_at DESC"
332 ))
333 .bind(actor.id)
334 .fetch_all(pool)
335 .await?)
336}