1use super::*;
4
5#[derive(Clone, Debug)]
6pub struct RequestInput {
7 pub title: String,
8 pub description: String,
10 pub reason: RequestReason,
11 pub priority: Priority,
12 pub due_date: Option<NaiveDate>,
13}
14
15#[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
35pub 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
45async 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(¬e)
95 .execute(&mut *conn)
96 .await?
97 .rows_affected();
98 Ok((inserted > 0).then_some(label))
99}
100
101pub 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
115pub 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 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 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 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 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
363pub(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(¬e, 500))
410 },
411 )
412 .await?;
413 }
414 Ok(())
415}
416
417pub 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}