1use sqlx::{Row, SqlitePool};
5
6pub use nyx_agent_types::integration::{
7 CreateProjectIntegrationRequest, PatchProjectIntegrationRequest, ProjectIntegrationEvent,
8 ProjectIntegrationKind, ProjectIntegrationRecord,
9};
10
11use crate::store::StoreError;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ProjectIntegrationStoredRecord {
15 pub public: ProjectIntegrationRecord,
16 pub config_json: String,
17}
18
19#[derive(Debug, Clone)]
20pub struct ProjectIntegrationInsert {
21 pub id: String,
22 pub project_id: String,
23 pub kind: ProjectIntegrationKind,
24 pub name: String,
25 pub enabled: bool,
26 pub events: Vec<ProjectIntegrationEvent>,
27 pub min_severity: Option<String>,
28 pub config_json: String,
29 pub target: String,
30 pub now_ms: i64,
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct ProjectIntegrationPatch {
35 pub name: Option<String>,
36 pub enabled: Option<bool>,
37 pub events: Option<Vec<ProjectIntegrationEvent>>,
38 pub min_severity: Option<Option<String>>,
39 pub config_json: Option<String>,
40 pub target: Option<String>,
41 pub updated_at: i64,
42}
43
44pub struct ProjectIntegrationStore<'a> {
45 pool: &'a SqlitePool,
46}
47
48impl<'a> ProjectIntegrationStore<'a> {
49 pub fn new(pool: &'a SqlitePool) -> Self {
50 Self { pool }
51 }
52
53 pub async fn create(
54 &self,
55 rec: ProjectIntegrationInsert,
56 ) -> Result<ProjectIntegrationRecord, StoreError> {
57 sqlx::query(
58 r#"
59 INSERT INTO project_integrations (
60 id, project_id, kind, name, enabled, events_json, min_severity,
61 config_json, target, created_at, updated_at
62 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63 "#,
64 )
65 .bind(&rec.id)
66 .bind(&rec.project_id)
67 .bind(rec.kind.as_str())
68 .bind(&rec.name)
69 .bind(i64::from(rec.enabled))
70 .bind(serde_json::to_string(&rec.events).map_err(StoreError::IntegrationJson)?)
71 .bind(&rec.min_severity)
72 .bind(&rec.config_json)
73 .bind(&rec.target)
74 .bind(rec.now_ms)
75 .bind(rec.now_ms)
76 .execute(self.pool)
77 .await?;
78 self.get(&rec.id).await?.ok_or(StoreError::Sqlx(sqlx::Error::RowNotFound))
79 }
80
81 pub async fn get(&self, id: &str) -> Result<Option<ProjectIntegrationRecord>, StoreError> {
82 Ok(self.get_stored(id).await?.map(|r| r.public))
83 }
84
85 pub async fn get_stored(
86 &self,
87 id: &str,
88 ) -> Result<Option<ProjectIntegrationStoredRecord>, StoreError> {
89 let row = sqlx::query(
90 r#"
91 SELECT id, project_id, kind, name, enabled, events_json, min_severity,
92 config_json, target, created_at, updated_at, last_delivery_at,
93 last_delivery_status, last_delivery_error
94 FROM project_integrations
95 WHERE id = ?
96 "#,
97 )
98 .bind(id)
99 .fetch_optional(self.pool)
100 .await?;
101 row.map(row_to_integration).transpose()
102 }
103
104 pub async fn list_by_project(
105 &self,
106 project_id: &str,
107 ) -> Result<Vec<ProjectIntegrationRecord>, StoreError> {
108 let rows = sqlx::query(
109 r#"
110 SELECT id, project_id, kind, name, enabled, events_json, min_severity,
111 config_json, target, created_at, updated_at, last_delivery_at,
112 last_delivery_status, last_delivery_error
113 FROM project_integrations
114 WHERE project_id = ?
115 ORDER BY created_at DESC, name
116 "#,
117 )
118 .bind(project_id)
119 .fetch_all(self.pool)
120 .await?;
121 rows.into_iter().map(row_to_integration_public).collect()
122 }
123
124 pub async fn list_enabled_by_project(
125 &self,
126 project_id: &str,
127 ) -> Result<Vec<ProjectIntegrationStoredRecord>, StoreError> {
128 let rows = sqlx::query(
129 r#"
130 SELECT id, project_id, kind, name, enabled, events_json, min_severity,
131 config_json, target, created_at, updated_at, last_delivery_at,
132 last_delivery_status, last_delivery_error
133 FROM project_integrations
134 WHERE project_id = ? AND enabled = 1
135 ORDER BY created_at ASC
136 "#,
137 )
138 .bind(project_id)
139 .fetch_all(self.pool)
140 .await?;
141 rows.into_iter().map(row_to_integration).collect()
142 }
143
144 pub async fn update(
145 &self,
146 id: &str,
147 patch: ProjectIntegrationPatch,
148 ) -> Result<Option<ProjectIntegrationRecord>, StoreError> {
149 let Some(existing) = self.get_stored(id).await? else {
150 return Ok(None);
151 };
152 let public = existing.public;
153 let name = patch.name.unwrap_or(public.name);
154 let enabled = patch.enabled.unwrap_or(public.enabled);
155 let events = patch.events.unwrap_or(public.events);
156 let min_severity = patch.min_severity.unwrap_or(public.min_severity);
157 let config_json = patch.config_json.unwrap_or(existing.config_json);
158 let target = patch.target.unwrap_or(public.target);
159 sqlx::query(
160 r#"
161 UPDATE project_integrations SET
162 name = ?,
163 enabled = ?,
164 events_json = ?,
165 min_severity = ?,
166 config_json = ?,
167 target = ?,
168 updated_at = ?
169 WHERE id = ?
170 "#,
171 )
172 .bind(name)
173 .bind(i64::from(enabled))
174 .bind(serde_json::to_string(&events).map_err(StoreError::IntegrationJson)?)
175 .bind(min_severity)
176 .bind(config_json)
177 .bind(target)
178 .bind(patch.updated_at)
179 .bind(id)
180 .execute(self.pool)
181 .await?;
182 self.get(id).await
183 }
184
185 pub async fn delete(&self, id: &str) -> Result<u64, StoreError> {
186 let res = sqlx::query("DELETE FROM project_integrations WHERE id = ?")
187 .bind(id)
188 .execute(self.pool)
189 .await?;
190 Ok(res.rows_affected())
191 }
192
193 pub async fn record_delivery(
194 &self,
195 id: &str,
196 at_ms: i64,
197 status: &str,
198 error: Option<&str>,
199 ) -> Result<(), StoreError> {
200 sqlx::query(
201 r#"
202 UPDATE project_integrations SET
203 last_delivery_at = ?,
204 last_delivery_status = ?,
205 last_delivery_error = ?
206 WHERE id = ?
207 "#,
208 )
209 .bind(at_ms)
210 .bind(status)
211 .bind(error)
212 .bind(id)
213 .execute(self.pool)
214 .await?;
215 Ok(())
216 }
217}
218
219fn row_to_integration_public(
220 row: sqlx::sqlite::SqliteRow,
221) -> Result<ProjectIntegrationRecord, StoreError> {
222 row_to_integration(row).map(|r| r.public)
223}
224
225fn row_to_integration(
226 row: sqlx::sqlite::SqliteRow,
227) -> Result<ProjectIntegrationStoredRecord, StoreError> {
228 let kind_raw: String = row.try_get("kind")?;
229 let kind = kind_raw
230 .parse::<ProjectIntegrationKind>()
231 .map_err(|()| StoreError::InvalidIntegrationKind(kind_raw.clone()))?;
232 let events_json: String = row.try_get("events_json")?;
233 let events = serde_json::from_str(&events_json).map_err(StoreError::IntegrationJson)?;
234 Ok(ProjectIntegrationStoredRecord {
235 public: ProjectIntegrationRecord {
236 id: row.try_get("id")?,
237 project_id: row.try_get("project_id")?,
238 kind,
239 name: row.try_get("name")?,
240 enabled: row.try_get::<i64, _>("enabled")? != 0,
241 events,
242 min_severity: row.try_get("min_severity")?,
243 target: row.try_get("target")?,
244 created_at: row.try_get::<i64, _>("created_at")?,
245 updated_at: row.try_get::<i64, _>("updated_at")?,
246 last_delivery_at: row.try_get("last_delivery_at")?,
247 last_delivery_status: row.try_get("last_delivery_status")?,
248 last_delivery_error: row.try_get("last_delivery_error")?,
249 },
250 config_json: row.try_get("config_json")?,
251 })
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::store::testutil::fresh_store;
258
259 fn insert(project_id: &str, id: &str, enabled: bool) -> ProjectIntegrationInsert {
260 ProjectIntegrationInsert {
261 id: id.to_string(),
262 project_id: project_id.to_string(),
263 kind: ProjectIntegrationKind::Webhook,
264 name: id.to_string(),
265 enabled,
266 events: vec![ProjectIntegrationEvent::RunFinished],
267 min_severity: Some("High".to_string()),
268 config_json: r#"{"kind":"webhook","url":"https://example.invalid"}"#.to_string(),
269 target: "example.invalid".to_string(),
270 now_ms: 1_000,
271 }
272 }
273
274 #[tokio::test]
275 async fn create_then_list_by_project_roundtrips() {
276 let (_tmp, s) = fresh_store().await;
277 s.integrations()
278 .create(insert(crate::store::DEFAULT_PROJECT_ID, "int-1", true))
279 .await
280 .expect("create");
281 let rows =
282 s.integrations().list_by_project(crate::store::DEFAULT_PROJECT_ID).await.unwrap();
283 assert_eq!(rows.len(), 1);
284 assert_eq!(rows[0].target, "example.invalid");
285 assert_eq!(rows[0].events, vec![ProjectIntegrationEvent::RunFinished]);
286 }
287
288 #[tokio::test]
289 async fn enabled_list_excludes_disabled() {
290 let (_tmp, s) = fresh_store().await;
291 s.integrations()
292 .create(insert(crate::store::DEFAULT_PROJECT_ID, "on", true))
293 .await
294 .expect("on");
295 s.integrations()
296 .create(insert(crate::store::DEFAULT_PROJECT_ID, "off", false))
297 .await
298 .expect("off");
299 let rows = s
300 .integrations()
301 .list_enabled_by_project(crate::store::DEFAULT_PROJECT_ID)
302 .await
303 .unwrap();
304 assert_eq!(rows.iter().map(|r| r.public.id.as_str()).collect::<Vec<_>>(), vec!["on"]);
305 }
306
307 #[tokio::test]
308 async fn project_delete_cascades() {
309 let (_tmp, s) = fresh_store().await;
310 s.integrations()
311 .create(insert(crate::store::DEFAULT_PROJECT_ID, "int-1", true))
312 .await
313 .expect("create");
314 s.projects().delete(crate::store::DEFAULT_PROJECT_ID).await.expect("delete");
315 assert!(s.integrations().get("int-1").await.expect("get").is_none());
316 }
317}