Skip to main content

character_service/
store.rs

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
19/// A character's columns, as [`Row`] reads them.
20macro_rules! columns {
21    () => {
22        "id, campaign, owners, sheet, version, created_at, updated_at"
23    };
24}
25
26/// Whose characters, by which rules, and who's asking: every operation is
27/// scoped to one campaign, spell slots follow the classes under its
28/// edition, and a player's device changes only the characters it owns.
29#[derive(Debug, Clone)]
30pub struct Scope {
31    pub campaign: Uuid,
32    pub edition: Edition,
33    /// The player device asking, by key; `None` for a DM device or a
34    /// service, which may change any character.
35    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    /// A stored character as it stands under the campaign's rules: sheets
50    /// saved before their slots followed the classes show the right ones.
51    fn view(&self, mut character: Character) -> Character {
52        character.sheet.derive_slots(self.edition);
53        character
54    }
55
56    /// Whether the asker may change `character`: a DM device or a service
57    /// may change any, a player's device only one it owns.
58    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/// Persistence for characters, in Postgres. Every operation is scoped to
70/// one campaign: a character of another campaign is simply not found.
71#[derive(Clone)]
72pub struct CharacterStore {
73    pool: PgPool,
74}
75
76impl CharacterStore {
77    /// A store in `pool`'s database.
78    pub fn new(pool: PgPool) -> Self {
79        Self { pool }
80    }
81
82    /// The campaign's characters.
83    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    /// How many characters `campaign` keeps.
98    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    /// One of the campaign's characters.
108    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    /// Creates a character, played by the device creating it if that's a
122    /// player's.
123    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            // A player's device plays what it creates.
135            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    /// Replaces the sheet if the stored version still equals
146    /// `expected_version` (`None` skips the check). The compare and the write
147    /// happen in one statement, so there's no window for another write.
148    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        // Versions are signed in the database; one beyond i64 can't match
161        // any row, and -1 never does either since versions start at 1.
162        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            // Either the character doesn't exist (get returns NotFound) or
181            // the version moved on; hand back the current state to merge onto.
182            None => Err(ApiError::VersionMismatch(Box::new(
183                self.get(scope, id).await?,
184            ))),
185        }
186    }
187
188    /// Applies `action` to the sheet, atomically: the character's row stays
189    /// locked until it's written, so concurrent actions (two clients
190    /// spending the same slot) take turns. With a `key`, only once: sent
191    /// again with the same key, the character is returned as it now is.
192    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        // Sheets saved before their slots followed the classes catch up here.
228        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    /// Sets which player devices play the character, by key. That's a
247    /// change like any other: the version moves on.
248    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    /// Deletes every character of a campaign; returns how many there were.
269    /// (Their applied actions go with them.)
270    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    /// Every character of `campaign`, as they are, to keep or move
279    /// elsewhere.
280    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    /// Takes in `campaign`'s characters exported elsewhere, as they were:
293    /// ids, owners, versions and dates. All or nothing, and only while it
294    /// has none here.
295    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    /// Deletes a character the device may change.
323    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
339/// The time, as precisely as Postgres keeps it (microseconds), so what's
340/// returned matches what's read back later.
341fn now() -> DateTime<Utc> {
342    Utc::now().trunc_subsecs(6)
343}
344
345/// Stores `character` as it is, id and version included.
346pub(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}