Skip to main content

max / makenotwork

8.3 KB · 228 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 + analytics (lazy-loaded).
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 { item })
60 }
61
62 /// Item details tab: name, description, tags, content editor, bundle contents.
63 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_details")]
64 pub(in crate::routes::pages::dashboard) async fn item_tab_details(
65 State(db): State<PgPool>,
66 AuthUser(session_user): AuthUser,
67 Path(id): Path<String>,
68 ) -> Result<impl IntoResponse> {
69 let (db_item, db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
70 let item_id = db_item.id;
71 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
72 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
73
74 let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
75 let children = db::bundles::get_bundle_items(&db, item_id).await?;
76 let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
77 let child_items: Vec<Item> = children
78 .iter()
79 .map(|child| {
80 let child_tags = vec![];
81 Item::from_db_list(
82 child,
83 &child_tags,
84 false,
85 true,
86 session_user.settlement_currency,
87 )
88 })
89 .collect();
90 let available =
91 db::bundles::get_bundleable_items(&db, db_project.id, Some(item_id)).await?;
92 let available_items: Vec<Item> = available
93 .iter()
94 .filter(|a| !bundle_child_ids.contains(&a.id))
95 .map(|a| {
96 let a_tags = vec![];
97 Item::from_db_list(a, &a_tags, false, true, session_user.settlement_currency)
98 })
99 .collect();
100 (child_items, available_items)
101 } else {
102 (vec![], vec![])
103 };
104
105 let db_sections = db::item_sections::list_by_item(&db, item_id).await?;
106 let sections: Vec<crate::types::ItemSection> = db_sections
107 .iter()
108 .map(crate::types::ItemSection::from)
109 .collect();
110
111 Ok(ItemDetailsTabTemplate {
112 item,
113 bundle_items,
114 bundleable_items,
115 sections,
116 })
117 }
118
119 /// Item pricing tab: PWYW settings, license keys, promo codes.
120 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_pricing")]
121 pub(in crate::routes::pages::dashboard) async fn item_tab_pricing(
122 State(db): State<PgPool>,
123 AuthUser(session_user): AuthUser,
124 Path(id): Path<String>,
125 ) -> Result<impl IntoResponse> {
126 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
127 let item_id = db_item.id;
128 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
129 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
130
131 let db_license_keys = if db_item.enable_license_keys {
132 db::license_keys::get_license_keys_by_item(&db, item_id).await?
133 } else {
134 vec![]
135 };
136 let license_keys: Vec<LicenseKeyRow> = db_license_keys
137 .into_iter()
138 .map(LicenseKeyRow::from)
139 .collect();
140
141 let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&db, item_id).await?;
142 let promo_codes: Vec<PromoCodeRow> =
143 db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
144
145 Ok(ItemPricingTabTemplate {
146 item,
147 license_keys,
148 promo_codes,
149 license_preset_options: crate::license_templates::preset_options(),
150 })
151 }
152
153 /// Item files tab: version upload + download table.
154 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_files")]
155 pub(in crate::routes::pages::dashboard) async fn item_tab_files(
156 State(db): State<PgPool>,
157 AuthUser(session_user): AuthUser,
158 Path(id): Path<String>,
159 ) -> Result<impl IntoResponse> {
160 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
161 let item_id = db_item.id;
162 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
163 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
164
165 let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
166 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
167
168 // The panel is described (`crate::quasi::item_files`); this template is the
169 // bespoke region inside it, which is the upload machine and nothing else.
170 let uploader = ItemVersionUploadTemplate {
171 item,
172 versions: versions.clone(),
173 }
174 .render()
175 .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))?;
176
177 Ok(axum::response::Html(crate::quasi::item_files::fragment(
178 &item_id.to_string(),
179 &versions,
180 &uploader,
181 )))
182 }
183
184 /// Item embed tab: copy-paste embed codes.
185 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_embed")]
186 pub(in crate::routes::pages::dashboard) async fn item_tab_embed(
187 State(db): State<PgPool>,
188 State(config): State<Config>,
189 AuthUser(session_user): AuthUser,
190 Path(id): Path<String>,
191 ) -> Result<impl IntoResponse> {
192 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
193 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
194 let is_audio = db_item.audio_s3_key.is_some();
195 let item = build_item_view(&db_item, &item_tags, session_user.settlement_currency);
196
197 Ok(ItemEmbedTabTemplate {
198 item,
199 host_url: config.host_url.clone(),
200 is_audio,
201 })
202 }
203
204 /// Item sales tab: transaction history with refund actions.
205 ///
206 /// Described (`b25dd957`). A fill: the address is parameterized, so the read
207 /// stays here and only the Refund write is on a nest. See
208 /// `crate::quasi::item_sales`.
209 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_sales")]
210 pub(in crate::routes::pages::dashboard) async fn item_tab_sales(
211 State(db): State<PgPool>,
212 AuthUser(session_user): AuthUser,
213 Path(id): Path<String>,
214 ) -> Result<impl IntoResponse> {
215 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
216 let item_id = db_item.id;
217
218 let sales = db::transactions::get_sales_by_item(&db, item_id, session_user.id).await?;
219 let rows = crate::quasi::item_sales::rows(&sales);
220 let export_route = format!("/api/export/items/{item_id}/sales");
221
222 Ok(axum::response::Html(crate::quasi::item_sales::fragment(
223 item_id,
224 &rows,
225 &export_route,
226 )))
227 }
228