Skip to main content

max / makenotwork

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