1use chrono::{DateTime, SubsecRound, Utc};
4use edda_auth::protocol::Claims;
5use edda_types::{
6 Edition,
7 monster::{Monster, MonsterSheet},
8};
9use sqlx::{PgExecutor, PgPool, types::Json};
10use uuid::Uuid;
11
12use crate::error::ApiError;
13
14type Row = (
15 Uuid,
16 Option<Uuid>,
17 Json<MonsterSheet>,
18 i64,
19 DateTime<Utc>,
20 DateTime<Utc>,
21);
22
23macro_rules! columns {
25 () => {
26 "id, campaign, sheet, version, created_at, updated_at"
27 };
28}
29
30#[derive(Debug, Clone, Copy)]
33pub struct Scope {
34 pub campaign: Uuid,
35 pub edition: Edition,
36}
37
38impl From<&Claims> for Scope {
39 fn from(claims: &Claims) -> Self {
40 Self {
41 campaign: claims.campaign,
42 edition: claims.edition,
43 }
44 }
45}
46
47#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
49pub struct Imported {
50 pub added: usize,
51 pub updated: usize,
52}
53
54#[derive(Clone)]
57pub struct MonsterStore {
58 pool: PgPool,
59}
60
61impl MonsterStore {
62 pub fn new(pool: PgPool) -> Self {
64 Self { pool }
65 }
66
67 pub fn pool(&self) -> &PgPool {
69 &self.pool
70 }
71
72 pub async fn list(&self, scope: Scope) -> Result<Vec<Monster>, ApiError> {
75 let rows: Vec<Row> = sqlx::query_as(concat!(
76 "SELECT ",
77 columns!(),
78 " FROM monsters \
79 WHERE campaign = $1 OR (campaign IS NULL AND (edition IS NULL OR edition = $2)) \
80 ORDER BY lower(name), campaign IS NULL"
81 ))
82 .bind(scope.campaign)
83 .bind(scope.edition.to_string())
84 .fetch_all(&self.pool)
85 .await?;
86 rows.into_iter().map(from_row).collect()
87 }
88
89 pub async fn get(&self, scope: Scope, id: Uuid) -> Result<Monster, ApiError> {
91 let row: Option<Row> = sqlx::query_as(concat!(
92 "SELECT ",
93 columns!(),
94 " FROM monsters WHERE id = $1 AND (campaign IS NULL OR campaign = $2)"
95 ))
96 .bind(id)
97 .bind(scope.campaign)
98 .fetch_optional(&self.pool)
99 .await?;
100 row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
101 }
102
103 pub async fn count_own(&self, campaign: Uuid) -> Result<i64, ApiError> {
105 let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM monsters WHERE campaign = $1")
106 .bind(campaign)
107 .fetch_one(&self.pool)
108 .await?;
109 Ok(count)
110 }
111
112 pub async fn create(&self, scope: Scope, sheet: MonsterSheet) -> Result<Monster, ApiError> {
114 sheet.validate()?;
115 insert(&self.pool, Some(scope.campaign), &sheet).await
116 }
117
118 pub async fn replace(
120 &self,
121 scope: Scope,
122 id: Uuid,
123 sheet: MonsterSheet,
124 ) -> Result<Monster, ApiError> {
125 sheet.validate()?;
126 self.owned(scope, id).await?;
127 update(&self.pool, id, &sheet).await
128 }
129
130 pub async fn delete(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
132 self.owned(scope, id).await?;
133 sqlx::query("DELETE FROM monsters WHERE id = $1")
134 .bind(id)
135 .execute(&self.pool)
136 .await?;
137 Ok(())
138 }
139
140 async fn owned(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
143 match self.get(scope, id).await?.campaign {
144 Some(_) => Ok(()),
145 None => Err(ApiError::Shared),
146 }
147 }
148
149 pub async fn import(
154 &self,
155 campaign: Option<Uuid>,
156 sheets: &[MonsterSheet],
157 ) -> Result<Imported, ApiError> {
158 for sheet in sheets {
159 sheet.validate()?;
160 }
161 let mut tx = self.pool.begin().await?;
162 let mut imported = Imported::default();
163 for sheet in sheets {
164 let existing: Option<(Uuid,)> = sqlx::query_as(
165 "SELECT id FROM monsters WHERE campaign IS NOT DISTINCT FROM $1 AND name = $2 \
166 AND source = $3 AND edition IS NOT DISTINCT FROM $4",
167 )
168 .bind(campaign)
169 .bind(&sheet.name)
170 .bind(&sheet.source)
171 .bind(sheet.edition.map(|e| e.to_string()))
172 .fetch_optional(&mut *tx)
173 .await?;
174 if let Some((id,)) = existing {
175 update(&mut *tx, id, sheet).await?;
176 imported.updated += 1;
177 } else {
178 insert(&mut *tx, campaign, sheet).await?;
179 imported.added += 1;
180 }
181 }
182 tx.commit().await?;
183 Ok(imported)
184 }
185
186 pub async fn export_own(&self, campaign: Uuid) -> Result<Vec<Monster>, ApiError> {
190 let rows: Vec<Row> = sqlx::query_as(concat!(
191 "SELECT ",
192 columns!(),
193 " FROM monsters WHERE campaign = $1 ORDER BY created_at, id"
194 ))
195 .bind(campaign)
196 .fetch_all(&self.pool)
197 .await?;
198 rows.into_iter().map(from_row).collect()
199 }
200
201 pub async fn delete_campaign(&self, campaign: Uuid) -> Result<u64, ApiError> {
202 let mut tx = self.pool.begin().await?;
203 sqlx::query("DELETE FROM rules_text WHERE campaign = $1")
204 .bind(campaign)
205 .execute(&mut *tx)
206 .await?;
207 let result = sqlx::query("DELETE FROM monsters WHERE campaign = $1")
208 .bind(campaign)
209 .execute(&mut *tx)
210 .await?;
211 tx.commit().await?;
212 Ok(result.rows_affected())
213 }
214}
215
216pub(crate) fn now() -> DateTime<Utc> {
219 Utc::now().trunc_subsecs(6)
220}
221
222async fn insert(
223 executor: impl PgExecutor<'_>,
224 campaign: Option<Uuid>,
225 sheet: &MonsterSheet,
226) -> Result<Monster, ApiError> {
227 let now = now();
228 let monster = Monster {
229 id: Uuid::new_v4(),
230 campaign,
231 sheet: sheet.clone(),
232 version: 1,
233 created_at: now,
234 updated_at: now,
235 };
236 insert_as_is(executor, &monster).await?;
237 Ok(monster)
238}
239
240pub(crate) async fn insert_as_is(
242 executor: impl PgExecutor<'_>,
243 monster: &Monster,
244) -> Result<(), ApiError> {
245 let sheet = &monster.sheet;
246 sqlx::query(
247 "INSERT INTO monsters (id, campaign, name, source, edition, sheet, version, created_at, \
248 updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
249 )
250 .bind(monster.id)
251 .bind(monster.campaign)
252 .bind(&sheet.name)
253 .bind(&sheet.source)
254 .bind(sheet.edition.map(|e| e.to_string()))
255 .bind(Json(sheet))
256 .bind(i64::try_from(monster.version).map_err(|e| ApiError::Corrupt(e.to_string()))?)
257 .bind(monster.created_at)
258 .bind(monster.updated_at)
259 .execute(executor)
260 .await?;
261 Ok(())
262}
263
264async fn update(
265 executor: impl PgExecutor<'_>,
266 id: Uuid,
267 sheet: &MonsterSheet,
268) -> Result<Monster, ApiError> {
269 let row: Option<Row> = sqlx::query_as(concat!(
270 "UPDATE monsters SET name = $1, source = $2, edition = $3, sheet = $4, \
271 version = version + 1, updated_at = $5 WHERE id = $6 RETURNING ",
272 columns!()
273 ))
274 .bind(&sheet.name)
275 .bind(&sheet.source)
276 .bind(sheet.edition.map(|e| e.to_string()))
277 .bind(Json(sheet))
278 .bind(now())
279 .bind(id)
280 .fetch_optional(executor)
281 .await?;
282 row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
283}
284
285fn from_row(
286 (id, campaign, Json(sheet), version, created_at, updated_at): Row,
287) -> Result<Monster, ApiError> {
288 Ok(Monster {
289 id,
290 campaign,
291 sheet,
292 version: u64::try_from(version).map_err(|e| ApiError::Corrupt(e.to_string()))?,
293 created_at,
294 updated_at,
295 })
296}