1use chrono::{DateTime, Utc};
28use serde::Serialize;
29use serde_json::{json, Map, Value};
30use sqlx::{PgConnection, PgPool};
31use uuid::Uuid;
32
33use crate::{
34 auth::{self, Actor},
35 domain::{self, Role, WebhookEvent},
36 error::{AppError, AppResult},
37 models::{Change, ChangeRequest, Revision},
38 services::audit,
39 util,
40 webhooks::TargetPolicy,
41};
42
43pub const SECRET_PREFIX: &str = "tc_whsec_";
45pub const MAX_WEBHOOKS: i64 = 100;
47
48#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
50pub struct Webhook {
51 pub id: Uuid,
52 pub name: String,
53 pub url: String,
54 pub secret_hint: String,
56 pub events: Vec<String>,
57 pub project_id: Option<Uuid>,
58 pub project_code: Option<String>,
59 pub active: bool,
60 pub created_by: Option<Uuid>,
61 pub created_by_name: Option<String>,
62 pub created_at: DateTime<Utc>,
63 pub updated_at: DateTime<Utc>,
64 pub secret_rotated_at: DateTime<Utc>,
65 pub pending: i64,
67 pub failed_recently: i64,
69 pub last_status: Option<String>,
71 pub last_delivery_at: Option<DateTime<Utc>>,
72}
73
74const WEBHOOK_SELECT: &str = "
75 SELECT w.id, w.name, w.url, w.secret_hint, w.events, w.project_id, p.code AS project_code,
76 w.active, w.created_by, u.display_name AS created_by_name,
77 w.created_at, w.updated_at, w.secret_rotated_at,
78 (SELECT COUNT(*) FROM webhook_deliveries d
79 WHERE d.webhook_id = w.id AND d.status = 'pending') AS pending,
80 (SELECT COUNT(*) FROM webhook_deliveries d
81 WHERE d.webhook_id = w.id AND d.status = 'failed'
82 AND d.updated_at > now() - interval '7 days') AS failed_recently,
83 last.status AS last_status, last.created_at AS last_delivery_at
84 FROM webhooks w
85 LEFT JOIN projects p ON p.id = w.project_id
86 LEFT JOIN users u ON u.id = w.created_by
87 LEFT JOIN LATERAL (
88 SELECT d.status, d.created_at FROM webhook_deliveries d
89 WHERE d.webhook_id = w.id ORDER BY d.created_at DESC LIMIT 1
90 ) last ON TRUE";
91
92#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
94pub struct Delivery {
95 pub id: Uuid,
97 pub webhook_id: Uuid,
98 pub event_id: Uuid,
100 pub event: String,
101 pub payload: Value,
102 pub status: String,
104 pub attempts: i32,
105 pub next_attempt_at: DateTime<Utc>,
106 pub last_status_code: Option<i32>,
107 pub last_error: String,
108 pub created_at: DateTime<Utc>,
109 pub updated_at: DateTime<Utc>,
110 pub delivered_at: Option<DateTime<Utc>>,
111}
112
113fn hook_free(n: usize) -> String {
117 format!(
118 "NOT EXISTS (SELECT 1 FROM webhooks hw WHERE hw.id = webhook_id AND hw.project_id = ANY(${n}))"
119 )
120}
121
122const DELIVERY_SELECT: &str = "
123 SELECT id, webhook_id, event_id, event, payload, status, attempts, next_attempt_at,
124 last_status_code, last_error, created_at, updated_at, delivered_at
125 FROM webhook_deliveries";
126
127#[derive(Clone, Debug)]
129pub struct WebhookInput {
130 pub name: String,
131 pub url: String,
132 pub events: Vec<String>,
133 pub project_id: Option<Uuid>,
134 pub active: bool,
135}
136
137struct Valid {
138 name: String,
139 url: String,
140 events: Vec<String>,
141}
142
143fn validate(policy: &TargetPolicy, input: &WebhookInput) -> AppResult<Valid> {
144 let name = domain::require_text("Name", &input.name, 100)?;
145 if input.url.trim().chars().count() > 2000 {
146 return Err(AppError::BadRequest(
147 "The URL is longer than 2000 characters.".into(),
148 ));
149 }
150 let url = policy
151 .check_url(&input.url)
152 .map_err(AppError::BadRequest)?
153 .to_string();
154 let mut events = Vec::new();
155 for raw in &input.events {
156 let event = WebhookEvent::parse(raw)
157 .ok_or_else(|| AppError::BadRequest(format!("Unknown event '{}'.", raw.trim())))?;
158 if !events.contains(&event) {
159 events.push(event);
160 }
161 }
162 if events.is_empty() {
163 return Err(AppError::BadRequest(
164 "Choose at least one event for the webhook.".into(),
165 ));
166 }
167 let events = WebhookEvent::ALL
169 .iter()
170 .filter(|e| events.contains(e))
171 .map(|e| e.as_str().to_string())
172 .collect();
173 Ok(Valid { name, url, events })
174}
175
176fn host_of(url: &str) -> String {
179 reqwest::Url::parse(url)
180 .ok()
181 .and_then(|u| u.host_str().map(str::to_string))
182 .unwrap_or_default()
183}
184
185fn new_secret() -> (String, String) {
186 let secret = auth::generate_token(SECRET_PREFIX);
187 let hint = format!("{SECRET_PREFIX}…{}", &secret[secret.len() - 4..]);
188 (secret, hint)
189}
190
191async fn ensure_project(
192 conn: &mut PgConnection,
193 actor: &Actor,
194 project_id: Option<Uuid>,
195) -> AppResult<()> {
196 if let Some(id) = project_id {
197 if actor.hidden_projects.contains(&id) {
199 return Err(AppError::BadRequest("Unknown project group.".into()));
200 }
201 let exists =
202 sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1)")
203 .bind(id)
204 .fetch_one(&mut *conn)
205 .await?;
206 if !exists {
207 return Err(AppError::BadRequest("Unknown project group.".into()));
208 }
209 }
210 Ok(())
211}
212
213pub async fn list(pool: &PgPool, actor: &Actor) -> AppResult<Vec<Webhook>> {
214 actor.require(Role::Admin)?;
215 Ok(sqlx::query_as::<_, Webhook>(&format!(
216 "{WEBHOOK_SELECT} ORDER BY lower(w.name), w.created_at"
217 ))
218 .fetch_all(pool)
219 .await?)
220}
221
222pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Webhook> {
223 actor.require(Role::Admin)?;
224 fetch(pool, id).await
225}
226
227async fn fetch(pool: &PgPool, id: Uuid) -> AppResult<Webhook> {
228 sqlx::query_as::<_, Webhook>(&format!("{WEBHOOK_SELECT} WHERE w.id = $1"))
229 .bind(id)
230 .fetch_optional(pool)
231 .await?
232 .ok_or_else(|| AppError::not_found("Webhook not found."))
233}
234
235async fn lock(conn: &mut PgConnection, id: Uuid) -> AppResult<(String, String, bool)> {
237 sqlx::query_as::<_, (String, String, bool)>(
238 "SELECT name, url, active FROM webhooks WHERE id = $1 FOR UPDATE",
239 )
240 .bind(id)
241 .fetch_optional(&mut *conn)
242 .await?
243 .ok_or_else(|| AppError::not_found("Webhook not found."))
244}
245
246pub async fn create(
261 pool: &PgPool,
262 actor: &Actor,
263 policy: &TargetPolicy,
264 input: WebhookInput,
265) -> AppResult<(Webhook, String)> {
266 actor.require(Role::Admin)?;
267 let v = validate(policy, &input)?;
268 let mut tx = pool.begin().await?;
269 sqlx::query("LOCK TABLE webhooks IN SHARE ROW EXCLUSIVE MODE")
271 .execute(&mut *tx)
272 .await?;
273 let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhooks")
274 .fetch_one(&mut *tx)
275 .await?;
276 if count >= MAX_WEBHOOKS {
277 return Err(AppError::Conflict(format!(
278 "There are already {MAX_WEBHOOKS} webhooks; delete one first."
279 )));
280 }
281 ensure_project(&mut tx, actor, input.project_id).await?;
282 let (secret, hint) = new_secret();
283 let id = Uuid::new_v4();
284 sqlx::query(
285 "INSERT INTO webhooks (id, name, url, secret, secret_hint, events, project_id, active, created_by)
286 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
287 )
288 .bind(id)
289 .bind(&v.name)
290 .bind(&v.url)
291 .bind(&secret)
292 .bind(&hint)
293 .bind(&v.events)
294 .bind(input.project_id)
295 .bind(input.active)
296 .bind(actor.id)
297 .execute(&mut *tx)
298 .await?;
299 audit::record(
300 &mut tx,
301 Some(actor),
302 "webhook.create",
303 "webhook",
304 Some(id),
305 format!("Created webhook {} → {}", v.name, host_of(&v.url)),
306 json!({ "host": host_of(&v.url), "events": v.events, "project_id": input.project_id,
307 "active": input.active }),
308 )
309 .await?;
310 tx.commit().await?;
311 Ok((fetch(pool, id).await?, secret))
312}
313
314pub async fn update(
328 pool: &PgPool,
329 actor: &Actor,
330 policy: &TargetPolicy,
331 id: Uuid,
332 input: WebhookInput,
333) -> AppResult<Webhook> {
334 actor.require(Role::Admin)?;
335 let v = validate(policy, &input)?;
336 let mut tx = pool.begin().await?;
337 let (old_name, old_url, was_active) = lock(&mut tx, id).await?;
338 let old_project: Option<Uuid> =
340 sqlx::query_scalar("SELECT project_id FROM webhooks WHERE id = $1")
341 .bind(id)
342 .fetch_one(&mut *tx)
343 .await?;
344 if old_project.is_some_and(|p| actor.hidden_projects.contains(&p)) {
345 return Err(AppError::not_found("Webhook not found."));
346 }
347 ensure_project(&mut tx, actor, input.project_id).await?;
348 sqlx::query(
349 "UPDATE webhooks SET name = $2, url = $3, events = $4, project_id = $5, active = $6,
350 updated_at = now()
351 WHERE id = $1",
352 )
353 .bind(id)
354 .bind(&v.name)
355 .bind(&v.url)
356 .bind(&v.events)
357 .bind(input.project_id)
358 .bind(input.active)
359 .execute(&mut *tx)
360 .await?;
361 let dropped = if was_active && !input.active {
362 sqlx::query(
363 "UPDATE webhook_deliveries
364 SET status = 'failed', last_error = 'The webhook was deactivated.', updated_at = now()
365 WHERE webhook_id = $1 AND status = 'pending'",
366 )
367 .bind(id)
368 .execute(&mut *tx)
369 .await?
370 .rows_affected()
371 } else {
372 0
373 };
374 audit::record(
375 &mut tx,
376 Some(actor),
377 "webhook.update",
378 "webhook",
379 Some(id),
380 format!(
381 "Changed webhook {}{}",
382 v.name,
383 match (was_active, input.active) {
384 (true, false) => " (deactivated)",
385 (false, true) => " (activated)",
386 _ => "",
387 }
388 ),
389 json!({ "name_before": old_name, "host_before": host_of(&old_url), "host": host_of(&v.url),
390 "url_changed": old_url != v.url, "events": v.events, "project_id": input.project_id,
391 "active": input.active, "pending_given_up": dropped }),
392 )
393 .await?;
394 tx.commit().await?;
395 fetch(pool, id).await
396}
397
398pub async fn delete(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<String> {
409 actor.require(Role::Admin)?;
410 let mut tx = pool.begin().await?;
411 let (name, url, _) = lock(&mut tx, id).await?;
412 let deliveries = sqlx::query("DELETE FROM webhook_deliveries WHERE webhook_id = $1")
413 .bind(id)
414 .execute(&mut *tx)
415 .await?
416 .rows_affected();
417 sqlx::query("DELETE FROM webhooks WHERE id = $1")
418 .bind(id)
419 .execute(&mut *tx)
420 .await?;
421 audit::record(
422 &mut tx,
423 Some(actor),
424 "webhook.delete",
425 "webhook",
426 Some(id),
427 format!("Deleted webhook {name} → {}", host_of(&url)),
428 json!({ "host": host_of(&url), "deliveries": deliveries }),
429 )
430 .await?;
431 tx.commit().await?;
432 Ok(name)
433}
434
435pub async fn rotate_secret(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<(Webhook, String)> {
448 actor.require(Role::Admin)?;
449 let mut tx = pool.begin().await?;
450 let (name, _, _) = lock(&mut tx, id).await?;
451 let (secret, hint) = new_secret();
452 sqlx::query(
453 "UPDATE webhooks SET secret = $2, secret_hint = $3, secret_rotated_at = now(), updated_at = now()
454 WHERE id = $1",
455 )
456 .bind(id)
457 .bind(&secret)
458 .bind(&hint)
459 .execute(&mut *tx)
460 .await?;
461 audit::record(
462 &mut tx,
463 Some(actor),
464 "webhook.rotate_secret",
465 "webhook",
466 Some(id),
467 format!("Rotated the secret of webhook {name}"),
468 json!({ "secret_hint": hint }),
469 )
470 .await?;
471 tx.commit().await?;
472 Ok((fetch(pool, id).await?, secret))
473}
474
475pub async fn send_test(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Uuid> {
488 actor.require(Role::Admin)?;
489 let mut tx = pool.begin().await?;
490 let (name, _, active) = lock(&mut tx, id).await?;
491 if !active {
492 return Err(AppError::Conflict(format!(
493 "Webhook {name} is inactive; activate it before sending a test."
494 )));
495 }
496 let event_id = Uuid::new_v4();
497 let mut payload = envelope("ping", event_id, Some(actor));
498 payload.insert(
499 "webhook".into(),
500 json!({ "id": id, "name": name, "web_url": format!("/admin/webhooks/{id}"),
501 "api_url": format!("/api/v1/webhooks/{id}") }),
502 );
503 payload.insert(
504 "message".into(),
505 json!("Test delivery – the webhook is reachable."),
506 );
507 let delivery_id = Uuid::new_v4();
508 sqlx::query(
509 "INSERT INTO webhook_deliveries (id, webhook_id, event_id, event, payload)
510 VALUES ($1, $2, $3, 'ping', $4)",
511 )
512 .bind(delivery_id)
513 .bind(id)
514 .bind(event_id)
515 .bind(Value::Object(payload))
516 .execute(&mut *tx)
517 .await?;
518 audit::record(
519 &mut tx,
520 Some(actor),
521 "webhook.test",
522 "webhook",
523 Some(id),
524 format!("Sent a test delivery to webhook {name}"),
525 json!({ "delivery_id": delivery_id }),
526 )
527 .await?;
528 tx.commit().await?;
529 Ok(delivery_id)
530}
531
532pub async fn retry(pool: &PgPool, actor: &Actor, delivery_id: Uuid) -> AppResult<Delivery> {
546 actor.require(Role::Admin)?;
547 let mut tx = pool.begin().await?;
548 let row = sqlx::query_as::<_, (Uuid, String, String, bool)>(&format!(
549 "SELECT d.webhook_id, d.status, w.name, w.active
550 FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id
551 WHERE d.id = $1 AND {} FOR UPDATE OF d",
552 crate::services::access::project_visible("w.project_id", 2)
553 ))
554 .bind(delivery_id)
555 .bind(&actor.hidden_projects)
556 .fetch_optional(&mut *tx)
557 .await?
558 .ok_or_else(|| AppError::not_found("Delivery not found."))?;
559 let (webhook_id, status, name, active) = row;
560 if status == "sent" {
561 return Err(AppError::Conflict(
562 "This delivery was already sent successfully.".into(),
563 ));
564 }
565 if !active {
566 return Err(AppError::Conflict(format!(
567 "Webhook {name} is inactive; activate it before retrying."
568 )));
569 }
570 sqlx::query(
571 "UPDATE webhook_deliveries
572 SET status = 'pending', attempts = 0, next_attempt_at = now(), updated_at = now()
573 WHERE id = $1",
574 )
575 .bind(delivery_id)
576 .execute(&mut *tx)
577 .await?;
578 audit::record(
579 &mut tx,
580 Some(actor),
581 "webhook.retry",
582 "webhook",
583 Some(webhook_id),
584 format!("Retried a delivery of webhook {name}"),
585 json!({ "delivery_id": delivery_id, "status_before": status }),
586 )
587 .await?;
588 tx.commit().await?;
589 get_delivery(pool, actor, delivery_id).await
590}
591
592pub async fn deliveries(
594 pool: &PgPool,
595 actor: &Actor,
596 webhook_id: Uuid,
597 limit: i64,
598) -> AppResult<Vec<Delivery>> {
599 actor.require(Role::Admin)?;
600 Ok(sqlx::query_as::<_, Delivery>(&format!(
601 "{DELIVERY_SELECT} WHERE webhook_id = $1 AND {}
602 ORDER BY created_at DESC, id LIMIT $2",
603 hook_free(3)
604 ))
605 .bind(webhook_id)
606 .bind(limit.clamp(1, 200))
607 .bind(&actor.hidden_projects)
608 .fetch_all(pool)
609 .await?)
610}
611
612pub async fn get_delivery(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<Delivery> {
613 actor.require(Role::Admin)?;
614 sqlx::query_as::<_, Delivery>(&format!(
615 "{DELIVERY_SELECT} WHERE id = $1 AND {}",
616 hook_free(2)
617 ))
618 .bind(id)
619 .bind(&actor.hidden_projects)
620 .fetch_optional(pool)
621 .await?
622 .ok_or_else(|| AppError::not_found("Delivery not found."))
623}
624
625fn envelope(event: &str, id: Uuid, actor: Option<&Actor>) -> Map<String, Value> {
630 let mut map = Map::new();
631 map.insert("event".into(), json!(event));
632 map.insert("id".into(), json!(id));
633 map.insert(
634 "occurred_at".into(),
635 json!(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
636 );
637 map.insert(
638 "actor".into(),
639 json!({ "username": actor.map(|a| a.username.as_str()).unwrap_or("system") }),
640 );
641 map
642}
643
644pub fn payload(event: WebhookEvent, id: Uuid, actor: Option<&Actor>, objects: Value) -> Value {
647 let mut map = envelope(event.as_str(), id, actor);
648 if let Value::Object(objects) = objects {
649 for (k, v) in objects {
650 map.entry(k).or_insert(v);
651 }
652 }
653 Value::Object(map)
654}
655
656pub async fn emit(
670 conn: &mut PgConnection,
671 actor: Option<&Actor>,
672 event: WebhookEvent,
673 projects: &[Option<Uuid>],
674 objects: Value,
675) -> AppResult<u64> {
676 let projects: Vec<Uuid> = projects.iter().flatten().copied().collect();
677 let event_id = Uuid::new_v4();
678 let body = payload(event, event_id, actor, objects);
679 Ok(sqlx::query(
680 "INSERT INTO webhook_deliveries (id, webhook_id, event_id, event, projects, payload)
681 SELECT gen_random_uuid(), w.id, $1, $2, $4,
682 CASE WHEN p.code IS NULL OR jsonb_typeof($3::jsonb -> 'revisions') IS DISTINCT FROM 'array'
683 THEN $3::jsonb
684 ELSE jsonb_set($3::jsonb, '{revisions}', COALESCE(
685 (SELECT jsonb_agg(r.value ORDER BY r.ordinality)
686 FROM jsonb_array_elements($3::jsonb -> 'revisions') WITH ORDINALITY AS r
687 WHERE r.value ->> 'project' = p.code),
688 '[]'::jsonb))
689 END
690 FROM webhooks w
691 LEFT JOIN projects p ON p.id = w.project_id
692 WHERE w.active AND $2 = ANY(w.events)
693 AND (w.project_id IS NULL OR w.project_id = ANY($4))
694 -- the webhook as a reader: its own group and the internal groups
695 AND NOT EXISTS (SELECT 1 FROM projects rp
696 WHERE rp.id = ANY($4) AND rp.visibility = 'restricted'
697 AND rp.id IS DISTINCT FROM w.project_id)
698 -- a group that switched webhooks off sends nothing, scoped or not
699 AND NOT EXISTS (SELECT 1 FROM project_policies wp
700 JOIN projects wpp ON wpp.id = wp.project_id
701 WHERE wp.project_id = ANY($4) AND wpp.visibility = 'restricted'
702 AND NOT wp.webhooks)",
703 )
704 .bind(event_id)
705 .bind(event.as_str())
706 .bind(body)
707 .bind(&projects)
708 .execute(&mut *conn)
709 .await?
710 .rows_affected())
711}
712
713pub(crate) async fn emit_each(
722 conn: &mut PgConnection,
723 actor: Option<&Actor>,
724 event: WebhookEvent,
725 each: Vec<(Option<Uuid>, Value)>,
726 change_projects: &[Option<Uuid>],
727) -> AppResult<u64> {
728 if each.is_empty() {
729 return Ok(0);
730 }
731 let change_projects: Vec<Uuid> = change_projects.iter().flatten().copied().collect();
732 let mut ids = Vec::with_capacity(each.len());
733 let mut projects = Vec::with_capacity(each.len());
734 let mut bodies = Vec::with_capacity(each.len());
735 for (project, objects) in each {
736 let id = Uuid::new_v4();
737 ids.push(id);
738 projects.push(project);
739 bodies.push(payload(event, id, actor, objects));
740 }
741 Ok(sqlx::query(
742 "INSERT INTO webhook_deliveries (id, webhook_id, event_id, event, projects, payload)
743 SELECT gen_random_uuid(), w.id, e.event_id, $1,
744 array_remove(ARRAY[e.project_id], NULL)
745 || CASE WHEN s.strip THEN '{}'::uuid[] ELSE $5::uuid[] END,
746 CASE WHEN s.strip THEN e.payload - 'change' ELSE e.payload END
747 FROM unnest($2::uuid[], $3::uuid[], $4::jsonb[]) AS e (event_id, project_id, payload)
748 JOIN webhooks w ON w.active AND $1 = ANY(w.events)
749 AND (w.project_id IS NULL OR w.project_id = e.project_id)
750 CROSS JOIN LATERAL (
751 SELECT EXISTS (SELECT 1 FROM projects cp
752 WHERE cp.id = ANY($5) AND cp.visibility = 'restricted'
753 AND cp.id IS DISTINCT FROM w.project_id)
754 OR EXISTS (SELECT 1 FROM project_policies wp
755 JOIN projects wpp ON wpp.id = wp.project_id
756 WHERE wp.project_id = ANY($5) AND wpp.visibility = 'restricted'
757 AND NOT wp.webhooks) AS strip
758 ) s
759 WHERE NOT EXISTS (SELECT 1 FROM projects rp
760 WHERE rp.id = e.project_id AND rp.visibility = 'restricted'
761 AND rp.id IS DISTINCT FROM w.project_id)
762 AND NOT EXISTS (SELECT 1 FROM project_policies wp
763 JOIN projects wpp ON wpp.id = wp.project_id
764 WHERE wp.project_id = e.project_id AND wpp.visibility = 'restricted'
765 AND NOT wp.webhooks)",
766 )
767 .bind(event.as_str())
768 .bind(&ids)
769 .bind(&projects)
770 .bind(&bodies)
771 .bind(&change_projects)
772 .execute(&mut *conn)
773 .await?
774 .rows_affected())
775}
776
777pub(crate) async fn wanted(conn: &mut PgConnection, events: &[WebhookEvent]) -> AppResult<bool> {
780 let names: Vec<&str> = events.iter().map(|e| e.as_str()).collect();
781 Ok(sqlx::query_scalar::<_, bool>(
782 "SELECT EXISTS(SELECT 1 FROM webhooks WHERE active AND events && $1::text[])",
783 )
784 .bind(&names)
785 .fetch_one(&mut *conn)
786 .await?)
787}
788
789pub fn revision_object(rev: &Revision, state: &str) -> Value {
791 json!({
792 "id": rev.id,
793 "item_id": rev.item_id,
794 "item_number": rev.item_number,
795 "item_type": rev.item_type,
796 "code": rev.code,
797 "label": format!("{}/{}", rev.item_number, rev.code),
798 "name": rev.name,
799 "state": state,
800 "project": rev.project_code,
801 "web_url": format!("/revisions/{}", rev.id),
802 "api_url": format!("/api/v1/items/{}/revisions/{}",
803 util::pct_encode(&rev.item_number), util::pct_encode(&rev.code)),
804 })
805}
806
807pub fn change_object(change: &Change, status: &str) -> Value {
809 json!({
810 "id": change.id,
811 "number": change.number,
812 "title": change.title,
813 "priority": change.priority,
814 "status": status,
815 "author": change.created_by_name,
816 "web_url": format!("/changes/{}", change.id),
817 "api_url": format!("/api/v1/changes/{}", util::pct_encode(&change.number)),
818 })
819}
820
821pub fn request_object(request: &ChangeRequest) -> Value {
823 json!({
824 "id": request.id,
825 "number": request.number,
826 "title": request.title,
827 "reason": request.reason,
828 "priority": request.priority,
829 "status": request.status,
830 "requested_by": request.requested_by_name,
831 "due_date": request.due_date,
832 "web_url": format!("/requests/{}", request.id),
833 "api_url": format!("/api/v1/change-requests/{}", util::pct_encode(&request.number)),
834 })
835}
836
837pub(crate) async fn change_revisions(
840 conn: &mut PgConnection,
841 change_id: Uuid,
842) -> AppResult<(Vec<Value>, Vec<Option<Uuid>>)> {
843 #[derive(sqlx::FromRow)]
844 struct Row {
845 id: Uuid,
846 item_id: Uuid,
847 item_number: String,
848 item_type: String,
849 code: String,
850 name: String,
851 state: String,
852 action: String,
853 project_id: Option<Uuid>,
854 project_code: Option<String>,
855 }
856 let rows = sqlx::query_as::<_, Row>(
857 "SELECT r.id, r.item_id, i.number AS item_number, i.item_type, r.code, r.name, r.state, ci.action,
858 i.project_id, p.code AS project_code
859 FROM change_items ci
860 JOIN revisions r ON r.id = ci.revision_id
861 JOIN items i ON i.id = r.item_id
862 LEFT JOIN projects p ON p.id = i.project_id
863 WHERE ci.change_id = $1
864 ORDER BY i.number, r.seq",
865 )
866 .bind(change_id)
867 .fetch_all(&mut *conn)
868 .await?;
869 let projects = rows.iter().map(|r| r.project_id).collect();
870 let objects = rows
871 .into_iter()
872 .map(|r| {
873 json!({
874 "id": r.id,
875 "item_id": r.item_id,
876 "item_number": r.item_number,
877 "item_type": r.item_type,
878 "code": r.code,
879 "label": format!("{}/{}", r.item_number, r.code),
880 "name": r.name,
881 "state": r.state,
882 "action": r.action,
883 "project": r.project_code,
884 "web_url": format!("/revisions/{}", r.id),
885 "api_url": format!("/api/v1/items/{}/revisions/{}",
886 util::pct_encode(&r.item_number), util::pct_encode(&r.code)),
887 })
888 })
889 .collect();
890 Ok((objects, projects))
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896
897 fn actor() -> Actor {
898 Actor {
899 id: Uuid::nil(),
900 username: "erika".into(),
901 display_name: "Erika".into(),
902 role: Role::Editor,
903 skin: String::new(),
904 preferred_cad: String::new(),
905 projects: Vec::new(),
906 hidden_projects: Vec::new(),
907 }
908 }
909
910 #[test]
911 fn envelope_and_objects() {
912 let id = Uuid::new_v4();
913 let p = payload(
914 WebhookEvent::RevisionObsoleted,
915 id,
916 Some(&actor()),
917 json!({ "revision": { "id": 1 }, "event": "spoofed" }),
918 );
919 assert_eq!(
920 p["event"], "revision.obsoleted",
921 "objects cannot override the envelope"
922 );
923 assert_eq!(p["id"], json!(id));
924 assert_eq!(p["actor"], json!({ "username": "erika" }));
925 assert_eq!(p["revision"]["id"], 1);
926 let at = p["occurred_at"].as_str().unwrap();
927 assert!(chrono::DateTime::parse_from_rfc3339(at).is_ok() && at.ends_with('Z'));
928 assert_eq!(
929 payload(WebhookEvent::ChangeReleased, id, None, json!({}))["actor"]["username"],
930 "system"
931 );
932 }
933
934 #[test]
935 fn secrets_and_hints() {
936 let (secret, hint) = new_secret();
937 assert!(secret.starts_with(SECRET_PREFIX) && secret.len() == SECRET_PREFIX.len() + 64);
938 assert!(hint.starts_with(SECRET_PREFIX) && hint.ends_with(&secret[secret.len() - 4..]));
939 assert!(
940 hint.chars().count() < 20,
941 "the hint does not reveal the secret"
942 );
943 assert_ne!(new_secret().0, secret);
944 }
945
946 #[test]
947 fn input_validation() {
948 let policy = TargetPolicy::default();
949 let input = |url: &str, events: &[&str]| WebhookInput {
950 name: "ERP export".into(),
951 url: url.into(),
952 events: events.iter().map(|e| e.to_string()).collect(),
953 project_id: None,
954 active: true,
955 };
956 let v = validate(
957 &policy,
958 &input(
959 " https://erp.example.org/hook ",
960 &["change.released", "revision.released", "change.released"],
961 ),
962 )
963 .unwrap();
964 assert_eq!(v.url, "https://erp.example.org/hook");
965 assert_eq!(
966 v.events,
967 ["revision.released", "change.released"],
968 "canonical order, once"
969 );
970 assert!(validate(&policy, &input("https://erp.example.org", &[])).is_err());
971 assert!(validate(
972 &policy,
973 &input("https://erp.example.org", &["item.deleted"])
974 )
975 .is_err());
976 assert!(validate(
977 &policy,
978 &input("http://192.168.0.10/", &["change.released"])
979 )
980 .is_err());
981 assert!(validate(
982 &policy,
983 &input("ftp://x.example.org/", &["change.released"])
984 )
985 .is_err());
986 assert_eq!(
987 host_of("https://ci.example.org/x?token=abc"),
988 "ci.example.org"
989 );
990 }
991
992 #[test]
993 fn object_urls_are_relative_and_encoded() {
994 let now = Utc::now();
995 let rev = Revision {
996 id: Uuid::nil(),
997 item_id: Uuid::nil(),
998 item_number: "PRT-000001".into(),
999 item_type: "part".into(),
1000 unit: "each".into(),
1001 project_id: None,
1002 project_code: Some("ROVER".into()),
1003 seq: 1,
1004 code: "A".into(),
1005 state: "in_review".into(),
1006 name: "Bracket".into(),
1007 description: String::new(),
1008 material: String::new(),
1009 mass_kg: None,
1010 unit_cost: None,
1011 attributes: serde_json::json!({}),
1012 classification: "make".into(),
1013 checked_out_by: None,
1014 checked_out_by_name: None,
1015 checked_out_at: None,
1016 created_by: Uuid::nil(),
1017 created_by_name: "x".into(),
1018 created_at: now,
1019 updated_at: now,
1020 released_at: None,
1021 };
1022 let o = revision_object(&rev, "released");
1023 assert_eq!(o["state"], "released");
1024 assert_eq!(o["label"], "PRT-000001/A");
1025 assert_eq!(o["project"], "ROVER");
1026 assert_eq!(o["web_url"], format!("/revisions/{}", Uuid::nil()));
1027 assert_eq!(o["api_url"], "/api/v1/items/PRT-000001/revisions/A");
1028 }
1029}