1use chrono::{DateTime, SubsecRound, Utc};
2use edda_auth::protocol::{Claims, Principal};
3use edda_types::{Character, CharacterAction, CharacterSheet, Edition};
4use sqlx::{PgPool, types::Json};
5use uuid::Uuid;
6
7use crate::error::ApiError;
8
9type Row = (
10 Uuid,
11 Uuid,
12 Vec<String>,
13 Json<CharacterSheet>,
14 i64,
15 DateTime<Utc>,
16 DateTime<Utc>,
17);
18
19macro_rules! columns {
21 () => {
22 "id, campaign, owners, sheet, version, created_at, updated_at"
23 };
24}
25
26#[derive(Debug, Clone)]
30pub struct Scope {
31 pub campaign: Uuid,
32 pub edition: Edition,
33 pub player: Option<String>,
36}
37
38impl From<&Claims> for Scope {
39 fn from(claims: &Claims) -> Self {
40 Self {
41 campaign: claims.campaign,
42 edition: claims.edition,
43 player: (claims.role == Principal::Player).then(|| claims.key.to_string()),
44 }
45 }
46}
47
48impl Scope {
49 fn view(&self, mut character: Character) -> Character {
52 character.sheet.derive_slots(self.edition);
53 character
54 }
55
56 fn may_change(&self, character: &Character) -> Result<(), ApiError> {
59 match &self.player {
60 Some(key) if !character.owners.contains(key) => Err(ApiError::Forbidden(format!(
61 "{} isn't yours; the DM or their player changes them",
62 character.sheet.name
63 ))),
64 _ => Ok(()),
65 }
66 }
67}
68
69#[derive(Clone)]
72pub struct CharacterStore {
73 pool: PgPool,
74}
75
76impl CharacterStore {
77 pub fn new(pool: PgPool) -> Self {
79 Self { pool }
80 }
81
82 pub async fn list(&self, scope: Scope) -> Result<Vec<Character>, ApiError> {
84 let rows: Vec<Row> = sqlx::query_as(concat!(
85 "SELECT ",
86 columns!(),
87 " FROM characters WHERE campaign = $1 ORDER BY lower(name)"
88 ))
89 .bind(scope.campaign)
90 .fetch_all(&self.pool)
91 .await?;
92 rows.into_iter()
93 .map(|row| Ok(scope.view(from_row(row)?)))
94 .collect()
95 }
96
97 pub async fn count(&self, campaign: Uuid) -> Result<i64, ApiError> {
99 let (count,): (i64,) =
100 sqlx::query_as("SELECT COUNT(*) FROM characters WHERE campaign = $1")
101 .bind(campaign)
102 .fetch_one(&self.pool)
103 .await?;
104 Ok(count)
105 }
106
107 pub async fn get(&self, scope: Scope, id: Uuid) -> Result<Character, ApiError> {
109 let row: Option<Row> = sqlx::query_as(concat!(
110 "SELECT ",
111 columns!(),
112 " FROM characters WHERE id = $1 AND campaign = $2"
113 ))
114 .bind(id)
115 .bind(scope.campaign)
116 .fetch_optional(&self.pool)
117 .await?;
118 Ok(scope.view(row.map(from_row).transpose()?.ok_or(ApiError::NotFound)?))
119 }
120
121 pub async fn create(
124 &self,
125 scope: Scope,
126 mut sheet: CharacterSheet,
127 ) -> Result<Character, ApiError> {
128 sheet.derive_slots(scope.edition);
129 sheet.validate()?;
130 let now = now();
131 let character = Character {
132 id: Uuid::new_v4(),
133 campaign: scope.campaign,
134 owners: scope.player.iter().cloned().collect(),
136 sheet,
137 version: 1,
138 created_at: now,
139 updated_at: now,
140 };
141 insert(&self.pool, &character).await?;
142 Ok(character)
143 }
144
145 pub async fn replace(
149 &self,
150 scope: Scope,
151 id: Uuid,
152 expected_version: Option<u64>,
153 mut sheet: CharacterSheet,
154 ) -> Result<Character, ApiError> {
155 if scope.player.is_some() {
156 scope.may_change(&self.get(scope.clone(), id).await?)?;
157 }
158 sheet.derive_slots(scope.edition);
159 sheet.validate()?;
160 let expected = expected_version.map(|v| i64::try_from(v).unwrap_or(-1));
163 let row: Option<Row> = sqlx::query_as(concat!(
164 "UPDATE characters SET name = $1, sheet = $2, updated_at = $3, version = version + 1 \
165 WHERE id = $4 AND campaign = $5 AND version = COALESCE($6, version) \
166 RETURNING ",
167 columns!(),
168 ""
169 ))
170 .bind(&sheet.name)
171 .bind(Json(&sheet))
172 .bind(Utc::now())
173 .bind(id)
174 .bind(scope.campaign)
175 .bind(expected)
176 .fetch_optional(&self.pool)
177 .await?;
178 match row {
179 Some(row) => from_row(row),
180 None => Err(ApiError::VersionMismatch(Box::new(
183 self.get(scope, id).await?,
184 ))),
185 }
186 }
187
188 pub async fn apply(
193 &self,
194 scope: Scope,
195 id: Uuid,
196 action: &CharacterAction,
197 key: Option<&str>,
198 ) -> Result<Character, ApiError> {
199 let mut tx = self.pool.begin().await?;
200 let row: Option<Row> = sqlx::query_as(concat!(
201 "SELECT ",
202 columns!(),
203 " FROM characters WHERE id = $1 AND campaign = $2 FOR UPDATE"
204 ))
205 .bind(id)
206 .bind(scope.campaign)
207 .fetch_optional(&mut *tx)
208 .await?;
209 let mut character = row.map(from_row).transpose()?.ok_or(ApiError::NotFound)?;
210 scope.may_change(&character)?;
211 if let Some(key) = key {
212 let first = sqlx::query(
213 "INSERT INTO applied_actions (character, key, applied_at) VALUES ($1, $2, now()) \
214 ON CONFLICT DO NOTHING",
215 )
216 .bind(id)
217 .bind(key)
218 .execute(&mut *tx)
219 .await?
220 .rows_affected()
221 == 1;
222 if !first {
223 return Ok(character);
224 }
225 }
226
227 character.sheet.derive_slots(scope.edition);
229 character.sheet.apply(action)?;
230 character.sheet.validate()?;
231 character.version += 1;
232 character.updated_at = now();
233
234 sqlx::query(
235 "UPDATE characters SET sheet = $1, version = version + 1, updated_at = $2 WHERE id = $3",
236 )
237 .bind(Json(&character.sheet))
238 .bind(character.updated_at)
239 .bind(id)
240 .execute(&mut *tx)
241 .await?;
242 tx.commit().await?;
243 Ok(character)
244 }
245
246 pub async fn set_owners(
249 &self,
250 scope: Scope,
251 id: Uuid,
252 owners: &[String],
253 ) -> Result<Character, ApiError> {
254 let row: Option<Row> = sqlx::query_as(concat!(
255 "UPDATE characters SET owners = $1, updated_at = now(), version = version + 1 \
256 WHERE id = $2 AND campaign = $3 RETURNING ",
257 columns!(),
258 ""
259 ))
260 .bind(owners)
261 .bind(id)
262 .bind(scope.campaign)
263 .fetch_optional(&self.pool)
264 .await?;
265 Ok(scope.view(row.map(from_row).transpose()?.ok_or(ApiError::NotFound)?))
266 }
267
268 pub async fn delete_campaign(&self, campaign: Uuid) -> Result<u64, ApiError> {
271 let result = sqlx::query("DELETE FROM characters WHERE campaign = $1")
272 .bind(campaign)
273 .execute(&self.pool)
274 .await?;
275 Ok(result.rows_affected())
276 }
277
278 pub async fn export_campaign(&self, campaign: Uuid) -> Result<Vec<Character>, ApiError> {
281 let rows: Vec<Row> = sqlx::query_as(concat!(
282 "SELECT ",
283 columns!(),
284 " FROM characters WHERE campaign = $1 ORDER BY created_at, id"
285 ))
286 .bind(campaign)
287 .fetch_all(&self.pool)
288 .await?;
289 rows.into_iter().map(from_row).collect()
290 }
291
292 pub async fn import_campaign(
296 &self,
297 campaign: Uuid,
298 characters: &[Character],
299 ) -> Result<u64, ApiError> {
300 if characters.iter().any(|c| c.campaign != campaign) {
301 return Err(ApiError::BadRequest(
302 "a character of another campaign is in the export".into(),
303 ));
304 }
305 let mut tx = self.pool.begin().await?;
306 let (kept,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM characters WHERE campaign = $1")
307 .bind(campaign)
308 .fetch_one(&mut *tx)
309 .await?;
310 if kept > 0 {
311 return Err(ApiError::Conflict(format!(
312 "campaign {campaign} has {kept} character(s) here already"
313 )));
314 }
315 for character in characters {
316 insert(&mut *tx, character).await?;
317 }
318 tx.commit().await?;
319 Ok(u64::try_from(characters.len()).unwrap_or(u64::MAX))
320 }
321
322 pub async fn delete(&self, scope: Scope, id: Uuid) -> Result<(), ApiError> {
324 if scope.player.is_some() {
325 scope.may_change(&self.get(scope.clone(), id).await?)?;
326 }
327 let result = sqlx::query("DELETE FROM characters WHERE id = $1 AND campaign = $2")
328 .bind(id)
329 .bind(scope.campaign)
330 .execute(&self.pool)
331 .await?;
332 if result.rows_affected() == 0 {
333 return Err(ApiError::NotFound);
334 }
335 Ok(())
336 }
337}
338
339fn now() -> DateTime<Utc> {
342 Utc::now().trunc_subsecs(6)
343}
344
345pub(crate) async fn insert(
347 db: impl sqlx::PgExecutor<'_>,
348 character: &Character,
349) -> Result<(), ApiError> {
350 sqlx::query(
351 "INSERT INTO characters (id, campaign, owners, name, sheet, version, created_at, updated_at) \
352 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
353 )
354 .bind(character.id)
355 .bind(character.campaign)
356 .bind(&character.owners)
357 .bind(&character.sheet.name)
358 .bind(Json(&character.sheet))
359 .bind(i64::try_from(character.version).map_err(|e| ApiError::Corrupt(e.to_string()))?)
360 .bind(character.created_at)
361 .bind(character.updated_at)
362 .execute(db)
363 .await?;
364 Ok(())
365}
366
367fn from_row(
368 (id, campaign, owners, Json(sheet), version, created_at, updated_at): Row,
369) -> Result<Character, ApiError> {
370 Ok(Character {
371 id,
372 campaign,
373 owners,
374 sheet,
375 version: u64::try_from(version).map_err(|e| ApiError::Corrupt(e.to_string()))?,
376 created_at,
377 updated_at,
378 })
379}