Skip to main content

teamcraft/services/requests/
raising.rs

1//! Raising and editing a request: input, affected items, comments.
2
3use super::*;
4
5#[derive(Clone, Debug)]
6pub struct RequestInput {
7    pub title: String,
8    /// Problem and proposed solution.
9    pub description: String,
10    pub reason: RequestReason,
11    pub priority: Priority,
12    pub due_date: Option<NaiveDate>,
13}
14
15/// An affected item, optionally with the revision the problem was found in.
16#[derive(Clone, Debug)]
17pub struct AffectedItem {
18    pub item_id: Uuid,
19    pub revision_id: Option<Uuid>,
20    pub note: String,
21}
22
23struct CleanInput {
24    title: String,
25    description: String,
26}
27
28fn clean(input: &RequestInput) -> AppResult<CleanInput> {
29    Ok(CleanInput {
30        title: domain::require_text("Title", &input.title, 200)?,
31        description: domain::limit_text("Description", &input.description, 20_000)?,
32    })
33}
34
35/// Parses an optional due date (`YYYY-MM-DD`; empty = none).
36pub fn parse_due_date(raw: Option<&str>) -> AppResult<Option<NaiveDate>> {
37    match raw.map(str::trim).filter(|s| !s.is_empty()) {
38        None => Ok(None),
39        Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
40            .map(Some)
41            .map_err(|_| AppError::BadRequest(format!("Invalid due date '{s}': use YYYY-MM-DD."))),
42    }
43}
44
45/// Inserts an affected item; returns its label (`PRT-000001` or `PRT-000001/B`)
46/// or `None` if it was already listed.
47async fn insert_item(
48    conn: &mut PgConnection,
49    actor: &Actor,
50    request_id: Uuid,
51    item: &AffectedItem,
52) -> AppResult<Option<String>> {
53    let note = domain::limit_text("Note", &item.note, 2000)?;
54    let (number, project_id) = sqlx::query_as::<_, (String, Option<Uuid>)>(
55        "SELECT number, project_id FROM items WHERE id = $1",
56    )
57    .bind(item.item_id)
58    .fetch_optional(&mut *conn)
59    .await?
60    .ok_or_else(access::not_found_item)?;
61    access::ensure_visible(actor, project_id, access::not_found_item())?;
62    let mut label = number.clone();
63    if let Some(rev_id) = item.revision_id {
64        let code = sqlx::query_scalar::<_, String>(
65            "SELECT code FROM revisions WHERE id = $1 AND item_id = $2",
66        )
67        .bind(rev_id)
68        .bind(item.item_id)
69        .fetch_optional(&mut *conn)
70        .await?
71        .ok_or_else(|| {
72            AppError::BadRequest(format!("The revision does not belong to {number}."))
73        })?;
74        label = format!("{number}/{code}");
75    }
76    let count = sqlx::query_scalar::<_, i64>(
77        "SELECT COUNT(*) FROM change_request_items WHERE request_id = $1",
78    )
79    .bind(request_id)
80    .fetch_one(&mut *conn)
81    .await?;
82    if count >= MAX_ITEMS {
83        return Err(AppError::BadRequest(format!(
84            "A change request can name at most {MAX_ITEMS} items."
85        )));
86    }
87    let inserted = sqlx::query(
88        "INSERT INTO change_request_items (request_id, item_id, revision_id, note)
89         VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
90    )
91    .bind(request_id)
92    .bind(item.item_id)
93    .bind(item.revision_id)
94    .bind(&note)
95    .execute(&mut *conn)
96    .await?
97    .rows_affected();
98    Ok((inserted > 0).then_some(label))
99}
100
101/// Rejects a list of more than [`MAX_ITEMS`] distinct affected items.
102/// Callers that resolve item references one by one (the REST API) drop
103/// repeated references and check this first, so a long list costs no
104/// lookups; [`create`] drops repeated items (one row per item, the first
105/// mention wins) and checks again.
106pub fn check_item_count(len: usize) -> AppResult<()> {
107    if len > MAX_ITEMS as usize {
108        return Err(AppError::BadRequest(format!(
109            "A change request can name at most {MAX_ITEMS} items."
110        )));
111    }
112    Ok(())
113}
114
115/// Raises a change request. Every signed-in user may do this, viewers
116/// included. The requester watches it from now on.
117pub async fn create(
118    pool: &PgPool,
119    actor: &Actor,
120    input: RequestInput,
121    affected: Vec<AffectedItem>,
122) -> AppResult<ChangeRequest> {
123    let mut seen = std::collections::HashSet::new();
124    let affected: Vec<AffectedItem> = affected
125        .into_iter()
126        .filter(|a| seen.insert(a.item_id))
127        .collect();
128    check_item_count(affected.len())?;
129    let text = clean(&input)?;
130    let mut tx = pool.begin().await?;
131    let number = domain::format_request_number(next_counter(&mut tx, "change_request").await?);
132    let id = Uuid::new_v4();
133    sqlx::query(
134        "INSERT INTO change_requests (id, number, title, description, reason, priority, status,
135                                      requested_by, due_date)
136         VALUES ($1, $2, $3, $4, $5, $6, 'new', $7, $8)",
137    )
138    .bind(id)
139    .bind(&number)
140    .bind(&text.title)
141    .bind(&text.description)
142    .bind(input.reason.as_str())
143    .bind(input.priority.as_str())
144    .bind(actor.id)
145    .bind(input.due_date)
146    .execute(&mut *tx)
147    .await?;
148    let mut labels = Vec::new();
149    let mut named = Vec::new();
150    for item in &affected {
151        if let Some(label) = insert_item(&mut tx, actor, id, item).await? {
152            labels.push(label);
153            named.push(item.item_id);
154        }
155    }
156    notifications::add_watch(&mut tx, actor.id, "change_request", id).await?;
157    // The details name the items: readers who later see the request (an
158    // item was removed, or as requester or assignee) but not all of them get
159    // a redacted entry.
160    audit::record_mentioning(
161        &mut tx,
162        Some(actor),
163        "change_request.create",
164        "change_request",
165        Some(id),
166        &audit::Mentions::items(named),
167        format!("Raised change request {number} – {}", text.title),
168        json!({ "reason": input.reason.as_str(), "priority": input.priority.as_str(), "items": labels }),
169    )
170    .await?;
171    let request = lock(&mut tx, actor, id).await?;
172    let recipients = triagers(&mut tx, id).await?;
173    tell(
174        &mut tx,
175        actor,
176        &request,
177        &recipients,
178        format!("Change requested: {number} – {}", text.title),
179        format!(
180            "{} raised a change request ({}){}.",
181            actor.display_name,
182            input.reason.label(),
183            if labels.is_empty() {
184                String::new()
185            } else {
186                format!(" on {}", labels.join(", "))
187            }
188        ),
189    )
190    .await?;
191    let projects = project_ids(&mut tx, id).await?;
192    webhooks::emit(
193        &mut tx,
194        Some(actor),
195        WebhookEvent::ChangeRequestCreated,
196        &projects,
197        json!({ "change_request": webhooks::request_object(&request), "items": labels }),
198    )
199    .await?;
200    tx.commit().await?;
201    get(pool, actor, id).await
202}
203
204pub async fn update(
205    pool: &PgPool,
206    actor: &Actor,
207    id: Uuid,
208    input: RequestInput,
209) -> AppResult<ChangeRequest> {
210    let text = clean(&input)?;
211    let mut tx = pool.begin().await?;
212    let request = lock(&mut tx, actor, id).await?;
213    let projects = project_ids(&mut tx, id).await?;
214    if !abilities(actor, &request, &projects).edit {
215        return Err(if status_of(&request).is_open() {
216            AppError::Forbidden(format!(
217                "Only the requester (before the decision), CAD editors of the affected project groups or an administrator can edit {}.",
218                request.number
219            ))
220        } else {
221            not_now(&request)
222        });
223    }
224    sqlx::query(
225        "UPDATE change_requests SET title = $2, description = $3, reason = $4, priority = $5,
226                due_date = $6, updated_at = now() WHERE id = $1",
227    )
228    .bind(id)
229    .bind(&text.title)
230    .bind(&text.description)
231    .bind(input.reason.as_str())
232    .bind(input.priority.as_str())
233    .bind(input.due_date)
234    .execute(&mut *tx)
235    .await?;
236    audit::record(
237        &mut tx,
238        Some(actor),
239        "change_request.update",
240        "change_request",
241        Some(id),
242        format!("Updated change request {}", request.number),
243        json!({ "reason": input.reason.as_str(), "priority": input.priority.as_str() }),
244    )
245    .await?;
246    tx.commit().await?;
247    get(pool, actor, id).await
248}
249
250pub async fn add_item(
251    pool: &PgPool,
252    actor: &Actor,
253    id: Uuid,
254    item: AffectedItem,
255) -> AppResult<String> {
256    let mut tx = pool.begin().await?;
257    let request = lock(&mut tx, actor, id).await?;
258    let projects = project_ids(&mut tx, id).await?;
259    if !abilities(actor, &request, &projects).edit {
260        return Err(if status_of(&request).is_open() {
261            forbidden(&request, "change the items of")
262        } else {
263            not_now(&request)
264        });
265    }
266    // A hidden item answers like a missing one, whatever the status.
267    let project =
268        sqlx::query_scalar::<_, Option<Uuid>>("SELECT project_id FROM items WHERE id = $1")
269            .bind(item.item_id)
270            .fetch_optional(&mut *tx)
271            .await?
272            .ok_or_else(access::not_found_item)?;
273    access::ensure_visible(actor, project, access::not_found_item())?;
274    // The assignee must be able to see every affected item (as in `assign`).
275    if let Some(assignee) = request.assignee {
276        let blind = sqlx::query_scalar::<_, String>(&format!(
277            "SELECT u.display_name FROM users u WHERE u.id = $1 AND NOT {}",
278            access::user_sees_project("u", "$2::uuid")
279        ))
280        .bind(assignee)
281        .bind(project)
282        .fetch_optional(&mut *tx)
283        .await?;
284        if let Some(name) = blind {
285            return Err(AppError::Conflict(format!(
286                "The assignee {name} cannot see this item (not a member of its restricted project group); assign somebody else first."
287            )));
288        }
289    }
290    // After the decision only members of the item's project group (or admins)
291    // add items: otherwise a CAD editor could accept a request while no item
292    // is named (any CAD editor may triage then) and attach items of foreign
293    // groups to the accepted request afterwards.
294    let decided = !matches!(
295        status_of(&request),
296        RequestStatus::New | RequestStatus::InTriage
297    );
298    if decided && !actor.can_edit_project(project) {
299        return Err(AppError::Forbidden(format!(
300            "{} is {}: only members of the item's project group or an administrator can add it now.",
301            request.number,
302            status_of(&request).label()
303        )));
304    }
305    let label = insert_item(&mut tx, actor, id, &item)
306        .await?
307        .ok_or_else(|| {
308            AppError::Conflict(format!("The item is already part of {}.", request.number))
309        })?;
310    touch(&mut tx, id).await?;
311    audit::record_related(
312        &mut tx,
313        Some(actor),
314        "change_request.add_item",
315        "change_request",
316        Some(id),
317        Some(item.item_id),
318        format!("Added {label} to {}", request.number),
319        json!({ "item_id": item.item_id, "revision_id": item.revision_id }),
320    )
321    .await?;
322    tx.commit().await?;
323    Ok(label)
324}
325
326pub async fn remove_item(pool: &PgPool, actor: &Actor, id: Uuid, item_id: Uuid) -> AppResult<()> {
327    let mut tx = pool.begin().await?;
328    let request = lock(&mut tx, actor, id).await?;
329    let projects = project_ids(&mut tx, id).await?;
330    if !abilities(actor, &request, &projects).edit {
331        return Err(if status_of(&request).is_open() {
332            forbidden(&request, "change the items of")
333        } else {
334            not_now(&request)
335        });
336    }
337    let number = sqlx::query_scalar::<_, String>(
338        "DELETE FROM change_request_items cri USING items i
339         WHERE cri.request_id = $1 AND cri.item_id = $2 AND i.id = cri.item_id
340         RETURNING i.number",
341    )
342    .bind(id)
343    .bind(item_id)
344    .fetch_optional(&mut *tx)
345    .await?
346    .ok_or_else(|| AppError::not_found("The item is not part of this change request."))?;
347    touch(&mut tx, id).await?;
348    audit::record_related(
349        &mut tx,
350        Some(actor),
351        "change_request.remove_item",
352        "change_request",
353        Some(id),
354        Some(item_id),
355        format!("Removed {number} from {}", request.number),
356        json!({ "item_id": item_id }),
357    )
358    .await?;
359    tx.commit().await?;
360    Ok(())
361}
362
363/// An item that is about to be deleted leaves every request that names it
364/// (the rows go with the item, `items::delete`). Each request records it in
365/// its history – with the note, so nothing is lost without a trace – and
366/// tells its people. The deleter may not see every such request; nothing
367/// here is returned to them, and the entries are shown only to readers of
368/// the request.
369pub(crate) async fn item_deleted(
370    conn: &mut PgConnection,
371    actor: &Actor,
372    item_id: Uuid,
373    item_number: &str,
374) -> AppResult<()> {
375    let rows = sqlx::query_as::<_, (Uuid, String)>(
376        "SELECT request_id, note FROM change_request_items WHERE item_id = $1 ORDER BY request_id",
377    )
378    .bind(item_id)
379    .fetch_all(&mut *conn)
380    .await?;
381    for (request_id, note) in rows {
382        let request = lock_unchecked(conn, request_id).await?;
383        touch(conn, request_id).await?;
384        audit::record(
385            conn,
386            Some(actor),
387            "change_request.remove_item",
388            "change_request",
389            Some(request_id),
390            format!(
391                "Removed {item_number} from {}: the item was deleted",
392                request.number
393            ),
394            json!({ "item_id": item_id, "item_number": item_number, "note": note,
395                    "reason": "item_deleted" }),
396        )
397        .await?;
398        tell_people(
399            conn,
400            actor,
401            &request,
402            format!(
403                "{} deleted {item_number}, an affected item of {}",
404                actor.display_name, request.number
405            ),
406            if note.is_empty() {
407                String::new()
408            } else {
409                format!("Its note was: {}", domain::truncate_chars(&note, 500))
410            },
411        )
412        .await?;
413    }
414    Ok(())
415}
416
417/// Adds a comment to the discussion. Every signed-in user may comment.
418pub async fn comment(
419    pool: &PgPool,
420    actor: &Actor,
421    id: Uuid,
422    body: &str,
423) -> AppResult<RequestComment> {
424    let body = domain::require_text("Comment", body, 10_000)?;
425    let mut tx = pool.begin().await?;
426    let request = lock(&mut tx, actor, id).await?;
427    let comment_id = Uuid::new_v4();
428    sqlx::query(
429        "INSERT INTO change_request_comments (id, request_id, author_id, body) VALUES ($1, $2, $3, $4)",
430    )
431    .bind(comment_id)
432    .bind(id)
433    .bind(actor.id)
434    .bind(&body)
435    .execute(&mut *tx)
436    .await?;
437    touch(&mut tx, id).await?;
438    audit::record(
439        &mut tx,
440        Some(actor),
441        "change_request.comment",
442        "change_request",
443        Some(id),
444        format!("Commented on {}", request.number),
445        json!({ "comment_id": comment_id }),
446    )
447    .await?;
448    tell_people(
449        &mut tx,
450        actor,
451        &request,
452        format!("{} commented on {}", actor.display_name, request.number),
453        domain::truncate_chars(&body, 500),
454    )
455    .await?;
456    let comment = sqlx::query_as::<_, RequestComment>(
457        "SELECT c.id, c.author_id, u.display_name AS author_name, c.body, c.created_at
458         FROM change_request_comments c JOIN users u ON u.id = c.author_id WHERE c.id = $1",
459    )
460    .bind(comment_id)
461    .fetch_one(&mut *tx)
462    .await?;
463    tx.commit().await?;
464    Ok(comment)
465}