1use serde_json::json;
9use sqlx::{PgConnection, PgPool};
10use uuid::Uuid;
11
12use crate::{
13 auth::Actor,
14 error::{AppError, AppResult},
15 resultscan::{ResultTemplate, TemplateInput, MAX_TEMPLATES},
16 services::{access, audit, projects},
17};
18
19const SELECT: &str = "
20 SELECT id, project_id, name, file_pattern, method, pattern, csv_column, csv_key_column,
21 csv_key_value, pick, unit, lower_limit, upper_limit, load_case, solver, enabled,
22 created_at, updated_at
23 FROM result_templates";
24
25fn not_found() -> AppError {
26 AppError::not_found("Extraction template not found.")
27}
28
29pub async fn list(
31 pool: &PgPool,
32 actor: &Actor,
33 project_id: Uuid,
34) -> AppResult<Vec<ResultTemplate>> {
35 access::ensure_visible(actor, Some(project_id), access::not_found_project())?;
36 Ok(sqlx::query_as::<_, ResultTemplate>(&format!(
37 "{SELECT} WHERE project_id = $1 ORDER BY lower(name), lower(load_case)"
38 ))
39 .bind(project_id)
40 .fetch_all(pool)
41 .await?)
42}
43
44pub async fn get(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ResultTemplate> {
46 let t = sqlx::query_as::<_, ResultTemplate>(&format!("{SELECT} WHERE id = $1"))
47 .bind(id)
48 .fetch_optional(pool)
49 .await?
50 .ok_or_else(not_found)?;
51 access::ensure_visible(actor, Some(t.project_id), not_found())?;
52 Ok(t)
53}
54
55pub(crate) async fn enabled_for(
58 conn: &mut PgConnection,
59 project_id: Uuid,
60) -> AppResult<Vec<ResultTemplate>> {
61 Ok(sqlx::query_as::<_, ResultTemplate>(&format!(
62 "{SELECT} WHERE project_id = $1 AND enabled ORDER BY lower(name), lower(load_case)"
63 ))
64 .bind(project_id)
65 .fetch_all(&mut *conn)
66 .await?)
67}
68
69fn describe(t: &TemplateInput) -> String {
70 format!("{} ({}, {})", t.name, t.method, t.file_pattern)
71}
72
73fn unique(e: sqlx::Error, input: &TemplateInput) -> AppError {
74 AppError::unique_violation(
75 e,
76 format!(
77 "This project group already has a template for '{}'{}.",
78 input.name,
79 if input.load_case.is_empty() {
80 String::new()
81 } else {
82 format!(" (load case '{}')", input.load_case)
83 }
84 ),
85 )
86}
87
88pub async fn create(
102 pool: &PgPool,
103 actor: &Actor,
104 project_id: Uuid,
105 input: TemplateInput,
106) -> AppResult<ResultTemplate> {
107 access::ensure_visible(actor, Some(project_id), access::not_found_project())?;
109 let input = input.validated().map_err(AppError::BadRequest)?;
110 let mut tx = pool.begin().await?;
111 let code =
113 sqlx::query_scalar::<_, String>("SELECT code FROM projects WHERE id = $1 FOR UPDATE")
114 .bind(project_id)
115 .fetch_optional(&mut *tx)
116 .await?
117 .ok_or_else(|| AppError::not_found("Project not found."))?;
118 projects::require_manager(&mut tx, actor, project_id).await?;
119 let count: i64 =
120 sqlx::query_scalar("SELECT COUNT(*) FROM result_templates WHERE project_id = $1")
121 .bind(project_id)
122 .fetch_one(&mut *tx)
123 .await?;
124 if count as usize >= MAX_TEMPLATES {
125 return Err(AppError::BadRequest(format!(
126 "A project group can have at most {MAX_TEMPLATES} extraction templates."
127 )));
128 }
129 let id = Uuid::new_v4();
130 sqlx::query(
131 "INSERT INTO result_templates (id, project_id, name, file_pattern, method, pattern,
132 csv_column, csv_key_column, csv_key_value, pick, unit, lower_limit, upper_limit,
133 load_case, solver, enabled, created_by)
134 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
135 )
136 .bind(id)
137 .bind(project_id)
138 .bind(&input.name)
139 .bind(&input.file_pattern)
140 .bind(&input.method)
141 .bind(&input.pattern)
142 .bind(&input.csv_column)
143 .bind(&input.csv_key_column)
144 .bind(&input.csv_key_value)
145 .bind(&input.pick)
146 .bind(&input.unit)
147 .bind(input.lower_limit)
148 .bind(input.upper_limit)
149 .bind(&input.load_case)
150 .bind(&input.solver)
151 .bind(input.enabled)
152 .bind(actor.id)
153 .execute(&mut *tx)
154 .await
155 .map_err(|e| unique(e, &input))?;
156 audit::record(
157 &mut tx,
158 Some(actor),
159 "result_template.create",
160 "project",
161 Some(project_id),
162 format!("Extraction template {} in project {code}", describe(&input)),
163 json!({ "template_id": id, "name": input.name, "file_pattern": input.file_pattern,
164 "method": input.method }),
165 )
166 .await?;
167 tx.commit().await?;
168 get(pool, actor, id).await
169}
170
171pub async fn update(
183 pool: &PgPool,
184 actor: &Actor,
185 id: Uuid,
186 input: TemplateInput,
187) -> AppResult<ResultTemplate> {
188 let existing = get(pool, actor, id).await?;
189 let input = input.validated().map_err(AppError::BadRequest)?;
190 let mut tx = pool.begin().await?;
191 projects::require_manager(&mut tx, actor, existing.project_id).await?;
192 let updated = sqlx::query(
193 "UPDATE result_templates SET name = $2, file_pattern = $3, method = $4, pattern = $5,
194 csv_column = $6, csv_key_column = $7, csv_key_value = $8, pick = $9, unit = $10,
195 lower_limit = $11, upper_limit = $12, load_case = $13, solver = $14,
196 enabled = $15, updated_at = now()
197 WHERE id = $1",
198 )
199 .bind(id)
200 .bind(&input.name)
201 .bind(&input.file_pattern)
202 .bind(&input.method)
203 .bind(&input.pattern)
204 .bind(&input.csv_column)
205 .bind(&input.csv_key_column)
206 .bind(&input.csv_key_value)
207 .bind(&input.pick)
208 .bind(&input.unit)
209 .bind(input.lower_limit)
210 .bind(input.upper_limit)
211 .bind(&input.load_case)
212 .bind(&input.solver)
213 .bind(input.enabled)
214 .execute(&mut *tx)
215 .await
216 .map_err(|e| unique(e, &input))?;
217 if updated.rows_affected() == 0 {
218 return Err(not_found());
219 }
220 audit::record(
221 &mut tx,
222 Some(actor),
223 "result_template.update",
224 "project",
225 Some(existing.project_id),
226 format!("Extraction template changed: {}", describe(&input)),
227 json!({ "template_id": id, "name": input.name, "file_pattern": input.file_pattern,
228 "method": input.method, "enabled": input.enabled }),
229 )
230 .await?;
231 tx.commit().await?;
232 get(pool, actor, id).await
233}
234
235pub async fn delete(pool: &PgPool, actor: &Actor, id: Uuid) -> AppResult<ResultTemplate> {
246 let existing = get(pool, actor, id).await?;
247 let mut tx = pool.begin().await?;
248 projects::require_manager(&mut tx, actor, existing.project_id).await?;
249 sqlx::query("DELETE FROM result_templates WHERE id = $1")
250 .bind(id)
251 .execute(&mut *tx)
252 .await?;
253 audit::record(
254 &mut tx,
255 Some(actor),
256 "result_template.delete",
257 "project",
258 Some(existing.project_id),
259 format!("Extraction template removed: {}", existing.name),
260 json!({ "template_id": id, "name": existing.name }),
261 )
262 .await?;
263 tx.commit().await?;
264 Ok(existing)
265}