Skip to main content

max / makenotwork

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