Skip to main content

max / makenotwork

9.2 KB · 259 lines History Blame Raw
1 //! Item-level dashboard tab handlers.
2
3 use askama::Template as _;
4 use axum::extract::{Path, State};
5 use axum::response::IntoResponse;
6
7 use crate::{
8 auth::AuthUser,
9 config::Config,
10 db::{self, ItemId},
11 error::{AppError, Result},
12 templates::{
13 ItemDetailsTabTemplate, ItemEmbedTabTemplate, ItemOverviewTabTemplate,
14 ItemPricingTabTemplate, ItemVersionUploadTemplate,
15 },
16 types::{Item, LicenseKeyRow, PromoCodeRow, Version},
17 };
18 use sqlx::PgPool;
19
20 /// Resolve an item by ID, verify ownership, and return the item + project.
21 async fn resolve_owned_item(
22 db: &PgPool,
23 user_id: db::UserId,
24 id: &str,
25 ) -> Result<(db::DbItem, db::DbProject)> {
26 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
27 let db_item = db::items::get_item_by_id(db, item_id)
28 .await?
29 .ok_or(AppError::NotFound)?;
30 let db_project = db::projects::get_project_by_id(db, db_item.project_id)
31 .await?
32 .ok_or(AppError::NotFound)?;
33 if db_project.user_id != user_id {
34 return Err(AppError::Forbidden);
35 }
36 Ok((db_item, db_project))
37 }
38
39 /// Build an Item view model from a DbItem (shared by item tab handlers).
40 fn build_item_view(
41 db_item: &db::DbItem,
42 item_tags: &[db::DbItemTag],
43 currency: crate::currency::SettlementCurrency,
44 ) -> Item {
45 let is_free = db_item.price_cents == 0;
46 Item::from_db_detail(db_item, item_tags, None, None, is_free, true, currency)
47 }
48
49 /// Item overview tab: quick actions and analytics, both in this response.
50 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_overview")]
51 pub(in crate::routes::pages::dashboard) async fn item_tab_overview(
52 State(db): State<PgPool>,
53 AuthUser(session_user): AuthUser,
54 Path(id): Path<String>,
55 ) -> Result<impl IntoResponse> {
56 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
57 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
58 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
59 Ok(ItemOverviewTabTemplate {
60 item,
61 analytics: super::super::main::item_analytics(
62 &db,
63 session_user.id,
64 db_item.id,
65 db::analytics::TimeRange::Days30,
66 )
67 .await?,
68 })
69 }
70
71 /// Item details tab: name, description, tags, content editor, bundle contents.
72 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_details")]
73 pub(in crate::routes::pages::dashboard) async fn item_tab_details(
74 State(db): State<PgPool>,
75 AuthUser(session_user): AuthUser,
76 Path(id): Path<String>,
77 ) -> Result<impl IntoResponse> {
78 let (db_item, db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
79 let item_id = db_item.id;
80 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
81 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
82
83 let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
84 let children = db::bundles::get_bundle_items(&db, item_id).await?;
85 let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
86 let child_items: Vec<Item> = children
87 .iter()
88 .map(|child| {
89 let child_tags = vec![];
90 Item::from_db_list(
91 child,
92 &child_tags,
93 false,
94 true,
95 session_user.settlement_currency,
96 )
97 })
98 .collect();
99 let available =
100 db::bundles::get_bundleable_items(&db, db_project.id, Some(item_id)).await?;
101 let available_items: Vec<Item> = available
102 .iter()
103 .filter(|a| !bundle_child_ids.contains(&a.id))
104 .map(|a| {
105 let a_tags = vec![];
106 Item::from_db_list(a, &a_tags, false, true, session_user.settlement_currency)
107 })
108 .collect();
109 (child_items, available_items)
110 } else {
111 (vec![], vec![])
112 };
113
114 let db_sections = db::item_sections::list_by_item(&db, item_id).await?;
115 let sections: Vec<crate::types::ItemSection> = db_sections
116 .iter()
117 .map(crate::types::ItemSection::from)
118 .collect();
119
120 // In this response rather than fetched on load. Same call the endpoint at
121 // `/api/items/{id}/tag-suggestions` makes, which stays for the refresh
122 // after a tag is added.
123 let suggested = db::tags::suggest_tags_for_item(
124 &db,
125 item_id,
126 &db_item.title,
127 db_item.description.as_deref(),
128 &db_item.item_type.to_string(),
129 )
130 .await?;
131 let tag_suggestions = crate::templates::TagSuggestionsTemplate {
132 suggestions: suggested
133 .into_iter()
134 .map(|t| crate::templates::TagSuggestion {
135 id: t.id.to_string(),
136 name: t.name,
137 })
138 .collect(),
139 };
140
141 Ok(ItemDetailsTabTemplate {
142 item,
143 bundle_items,
144 bundleable_items,
145 sections,
146 tag_suggestions,
147 })
148 }
149
150 /// Item pricing tab: PWYW settings, license keys, promo codes.
151 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_pricing")]
152 pub(in crate::routes::pages::dashboard) async fn item_tab_pricing(
153 State(db): State<PgPool>,
154 AuthUser(session_user): AuthUser,
155 Path(id): Path<String>,
156 ) -> Result<impl IntoResponse> {
157 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
158 let item_id = db_item.id;
159 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
160 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
161
162 let db_license_keys = if db_item.enable_license_keys {
163 db::license_keys::get_license_keys_by_item(&db, item_id).await?
164 } else {
165 vec![]
166 };
167 let license_keys: Vec<LicenseKeyRow> = db_license_keys
168 .into_iter()
169 .map(LicenseKeyRow::from)
170 .collect();
171
172 let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&db, item_id).await?;
173 let promo_codes: Vec<PromoCodeRow> =
174 db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
175
176 Ok(ItemPricingTabTemplate {
177 item,
178 license_keys,
179 promo_codes,
180 license_preset_options: crate::license_templates::preset_options(),
181 })
182 }
183
184 /// Item files tab: version upload + download table.
185 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_files")]
186 pub(in crate::routes::pages::dashboard) async fn item_tab_files(
187 State(db): State<PgPool>,
188 AuthUser(session_user): AuthUser,
189 Path(id): Path<String>,
190 ) -> Result<impl IntoResponse> {
191 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
192 let item_id = db_item.id;
193 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
194 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
195
196 let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
197 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
198
199 // The panel is described (`crate::quasi::item_files`); this template is the
200 // bespoke region inside it, which is the upload machine and nothing else.
201 let uploader = ItemVersionUploadTemplate {
202 item,
203 versions: versions.clone(),
204 }
205 .render()
206 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
207
208 Ok(axum::response::Html(crate::quasi::item_files::fragment(
209 &item_id.to_string(),
210 &versions,
211 &uploader,
212 )))
213 }
214
215 /// Item embed tab: copy-paste embed codes.
216 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_embed")]
217 pub(in crate::routes::pages::dashboard) async fn item_tab_embed(
218 State(db): State<PgPool>,
219 State(config): State<Config>,
220 AuthUser(session_user): AuthUser,
221 Path(id): Path<String>,
222 ) -> Result<impl IntoResponse> {
223 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
224 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
225 let is_audio = db_item.audio_s3_key.is_some();
226 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
227
228 Ok(ItemEmbedTabTemplate {
229 item,
230 host_url: config.host_url.clone(),
231 is_audio,
232 })
233 }
234
235 /// Item sales tab: transaction history with refund actions.
236 ///
237 /// Described (`b25dd957`). A fill: the address is parameterized, so the read
238 /// stays here and only the Refund write is on a nest. See
239 /// `crate::quasi::item_sales`.
240 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_sales")]
241 pub(in crate::routes::pages::dashboard) async fn item_tab_sales(
242 State(db): State<PgPool>,
243 AuthUser(session_user): AuthUser,
244 Path(id): Path<String>,
245 ) -> Result<impl IntoResponse> {
246 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
247 let item_id = db_item.id;
248
249 let sales = db::transactions::get_sales_by_item(&db, item_id, session_user.id).await?;
250 let rows = crate::quasi::item_sales::rows(&sales);
251 let export_route = format!("/api/export/items/{item_id}/sales");
252
253 Ok(axum::response::Html(crate::quasi::item_sales::fragment(
254 item_id,
255 &rows,
256 &export_route,
257 )))
258 }
259