1use axum::{
5 Json, Router,
6 extract::{Path, State},
7 http::{StatusCode, header},
8 response::IntoResponse,
9 routing::get,
10};
11use chrono::{DateTime, Utc};
12use edda_auth::{protocol::Claims, server::Authenticated};
13use edda_types::encounter::{Encounter, EncounterPlan};
14use sqlx::{PgPool, types::Json as Jsonb};
15use uuid::Uuid;
16
17use edda_server::openapi::ErrorBody;
18
19use crate::{error::ApiError, hub::Hub, store};
20
21pub(crate) type Row = (
22 Uuid,
23 String,
24 Jsonb<EncounterPlan>,
25 i64,
26 DateTime<Utc>,
27 DateTime<Utc>,
28);
29
30pub fn router() -> Router<std::sync::Arc<Hub>> {
32 Router::new()
33 .route("/encounters", get(list).post(create))
34 .route("/encounters/{id}", get(fetch).put(replace).delete(remove))
35}
36
37pub fn dm_only(claims: &Claims) -> Result<(), ApiError> {
39 if claims.is_dm() {
40 Ok(())
41 } else {
42 Err(ApiError::Forbidden(
43 "only a DM device can see planned encounters".into(),
44 ))
45 }
46}
47
48pub async fn plan(hub: &Hub, campaign: Uuid, id: Uuid) -> Result<EncounterPlan, ApiError> {
51 match get_one(hub, campaign, id).await {
52 Err(ApiError::NotFound) => Err(ApiError::BadRequest(
53 "there's no such planned encounter".into(),
54 )),
55 other => other.map(|e| e.plan),
56 }
57}
58
59fn check(plan: &EncounterPlan) -> Result<(), ApiError> {
60 if plan.name.trim().is_empty() {
61 return Err(ApiError::BadRequest("name must not be empty".into()));
62 }
63 if plan.monsters.iter().any(|m| m.count == 0) {
64 return Err(ApiError::BadRequest(
65 "every monster needs a count of at least 1".into(),
66 ));
67 }
68 Ok(())
69}
70
71pub(crate) fn from_row(
72 (id, campaign, Jsonb(plan), version, created_at, updated_at): Row,
73) -> Result<Encounter, ApiError> {
74 Ok(Encounter {
75 id,
76 campaign: campaign
77 .parse()
78 .map_err(|e: uuid::Error| ApiError::Corrupt(e.to_string()))?,
79 plan,
80 version: u64::try_from(version).map_err(|e| ApiError::Corrupt(e.to_string()))?,
81 created_at,
82 updated_at,
83 })
84}
85
86fn pool(hub: &Hub) -> &PgPool {
87 hub.store().pool()
88}
89
90#[utoipa::path(
91 get,
92 path = "/encounters",
93 tag = "encounters",
94 responses(
95 (status = 200, description = "The campaign's plans.", body = Vec<Encounter>),
96 (status = 403, description = "Not a DM device.", body = ErrorBody),
97 )
98)]
99async fn list(
101 State(hub): State<std::sync::Arc<Hub>>,
102 Authenticated(claims): Authenticated,
103) -> Result<Json<Vec<Encounter>>, ApiError> {
104 dm_only(&claims)?;
105 let rows: Vec<Row> = sqlx::query_as(
106 "SELECT id, campaign, plan, version, created_at, updated_at FROM encounters \
107 WHERE campaign = $1 ORDER BY lower(name)",
108 )
109 .bind(claims.campaign.to_string())
110 .fetch_all(pool(&hub))
111 .await?;
112 Ok(Json(
113 rows.into_iter().map(from_row).collect::<Result<_, _>>()?,
114 ))
115}
116
117async fn get_one(hub: &Hub, campaign: Uuid, id: Uuid) -> Result<Encounter, ApiError> {
118 let row: Option<Row> = sqlx::query_as(
119 "SELECT id, campaign, plan, version, created_at, updated_at FROM encounters \
120 WHERE id = $1 AND campaign = $2",
121 )
122 .bind(id)
123 .bind(campaign.to_string())
124 .fetch_optional(pool(hub))
125 .await?;
126 row.map(from_row).transpose()?.ok_or(ApiError::NotFound)
127}
128
129#[utoipa::path(
130 get,
131 path = "/encounters/{id}",
132 tag = "encounters",
133 params(("id" = Uuid, Path, description = "The plan's id.")),
134 responses((status = 200, description = "The plan.", body = Encounter), (status = 403, description = "Not a DM device.", body = ErrorBody), (status = 404, description = "No such plan in the campaign.", body = ErrorBody))
135)]
136async fn fetch(
137 State(hub): State<std::sync::Arc<Hub>>,
138 Authenticated(claims): Authenticated,
139 Path(id): Path<Uuid>,
140) -> Result<Json<Encounter>, ApiError> {
141 dm_only(&claims)?;
142 Ok(Json(get_one(&hub, claims.campaign, id).await?))
143}
144
145#[utoipa::path(
146 post,
147 path = "/encounters",
148 tag = "encounters",
149 request_body = EncounterPlan,
150 responses(
151 (status = 201, description = "Kept.", body = Encounter, headers(("Location" = String, description = "Where it is: `/encounters/{id}`."))),
152 (status = 403, description = "Not a DM device, or the campaign has as many plans as it may keep.", body = ErrorBody),
153 (status = 422, description = "Not a valid plan.", body = ErrorBody),
154 )
155)]
156async fn create(
157 State(hub): State<std::sync::Arc<Hub>>,
158 Authenticated(claims): Authenticated,
159 Json(plan): Json<EncounterPlan>,
160) -> Result<impl IntoResponse, ApiError> {
161 dm_only(&claims)?;
162 check(&plan)?;
163 let (kept,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM encounters WHERE campaign = $1")
164 .bind(claims.campaign.to_string())
165 .fetch_one(pool(&hub))
166 .await?;
167 let limits = hub.limits().with(&claims.settings.quotas);
168 limits
169 .room_for(kept, 1, limits.plans, "planned encounters")
170 .map_err(ApiError::Forbidden)?;
171 let now = store::now();
172 let encounter = Encounter {
173 id: Uuid::new_v4(),
174 campaign: claims.campaign,
175 plan,
176 version: 1,
177 created_at: now,
178 updated_at: now,
179 };
180 sqlx::query(
181 "INSERT INTO encounters (id, campaign, name, plan, version, created_at, updated_at) \
182 VALUES ($1, $2, $3, $4, 1, $5, $5)",
183 )
184 .bind(encounter.id)
185 .bind(claims.campaign.to_string())
186 .bind(&encounter.plan.name)
187 .bind(Jsonb(&encounter.plan))
188 .bind(now)
189 .execute(pool(&hub))
190 .await?;
191 let location = format!("/encounters/{}", encounter.id);
192 Ok((
193 StatusCode::CREATED,
194 [(header::LOCATION, location)],
195 Json(encounter),
196 ))
197}
198
199#[utoipa::path(
200 put,
201 path = "/encounters/{id}",
202 tag = "encounters",
203 params(("id" = Uuid, Path, description = "The plan's id.")),
204 request_body = EncounterPlan,
205 responses(
206 (status = 200, description = "Replaced.", body = Encounter),
207 (status = 403, description = "Not a DM device.", body = ErrorBody),
208 (status = 404, description = "No such plan in the campaign.", body = ErrorBody),
209 (status = 422, description = "Not a valid plan.", body = ErrorBody),
210 )
211)]
212async fn replace(
214 State(hub): State<std::sync::Arc<Hub>>,
215 Authenticated(claims): Authenticated,
216 Path(id): Path<Uuid>,
217 Json(plan): Json<EncounterPlan>,
218) -> Result<Json<Encounter>, ApiError> {
219 dm_only(&claims)?;
220 check(&plan)?;
221 let row: Option<Row> = sqlx::query_as(
222 "UPDATE encounters SET name = $1, plan = $2, version = version + 1, updated_at = $3 \
223 WHERE id = $4 AND campaign = $5 \
224 RETURNING id, campaign, plan, version, created_at, updated_at",
225 )
226 .bind(&plan.name)
227 .bind(Jsonb(&plan))
228 .bind(store::now())
229 .bind(id)
230 .bind(claims.campaign.to_string())
231 .fetch_optional(pool(&hub))
232 .await?;
233 Ok(Json(
234 row.map(from_row).transpose()?.ok_or(ApiError::NotFound)?,
235 ))
236}
237
238#[utoipa::path(
239 delete,
240 path = "/encounters/{id}",
241 tag = "encounters",
242 params(("id" = Uuid, Path, description = "The plan's id.")),
243 responses((status = 204, description = "Deleted."), (status = 403, description = "Not a DM device.", body = ErrorBody), (status = 404, description = "No such plan in the campaign.", body = ErrorBody))
244)]
245async fn remove(
246 State(hub): State<std::sync::Arc<Hub>>,
247 Authenticated(claims): Authenticated,
248 Path(id): Path<Uuid>,
249) -> Result<StatusCode, ApiError> {
250 dm_only(&claims)?;
251 let result = sqlx::query("DELETE FROM encounters WHERE id = $1 AND campaign = $2")
252 .bind(id)
253 .bind(claims.campaign.to_string())
254 .execute(pool(&hub))
255 .await?;
256 if result.rows_affected() == 0 {
257 return Err(ApiError::NotFound);
258 }
259 Ok(StatusCode::NO_CONTENT)
260}