Skip to main content

max / makenotwork

8.1 KB · 221 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, ItemSalesTabTemplate,
14 },
15 types::{Item, LicenseKeyRow, PromoCodeRow, SaleRow, 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(db_item: &db::DbItem, item_tags: &[db::DbItemTag]) -> Item {
40 let is_free = db_item.price_cents == 0;
41 Item::from_db_detail(db_item, item_tags, None, None, is_free, true)
42 }
43
44 /// Item overview tab: quick actions + analytics (lazy-loaded).
45 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_overview")]
46 pub(in crate::routes::pages::dashboard) async fn item_tab_overview(
47 State(db): State<PgPool>,
48 AuthUser(session_user): AuthUser,
49 Path(id): Path<String>,
50 ) -> Result<impl IntoResponse> {
51 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
52 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
53 let item = build_item_view(&db_item, &item_tags);
54 Ok(ItemOverviewTabTemplate { item })
55 }
56
57 /// Item details tab: name, description, tags, content editor, bundle contents.
58 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_details")]
59 pub(in crate::routes::pages::dashboard) async fn item_tab_details(
60 State(db): State<PgPool>,
61 AuthUser(session_user): AuthUser,
62 Path(id): Path<String>,
63 ) -> Result<impl IntoResponse> {
64 let (db_item, db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
65 let item_id = db_item.id;
66 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
67 let item = build_item_view(&db_item, &item_tags);
68
69 let (bundle_items, bundleable_items) = if db_item.item_type == db::ItemType::Bundle {
70 let children = db::bundles::get_bundle_items(&db, item_id).await?;
71 let bundle_child_ids: Vec<db::ItemId> = children.iter().map(|c| c.id).collect();
72 let child_items: Vec<Item> = children
73 .iter()
74 .map(|child| {
75 let child_tags = vec![];
76 Item::from_db_list(child, &child_tags, false, true)
77 })
78 .collect();
79 let available =
80 db::bundles::get_bundleable_items(&db, db_project.id, Some(item_id)).await?;
81 let available_items: Vec<Item> = available
82 .iter()
83 .filter(|a| !bundle_child_ids.contains(&a.id))
84 .map(|a| {
85 let a_tags = vec![];
86 Item::from_db_list(a, &a_tags, false, true)
87 })
88 .collect();
89 (child_items, available_items)
90 } else {
91 (vec![], vec![])
92 };
93
94 let db_sections = db::item_sections::list_by_item(&db, item_id).await?;
95 let sections: Vec<crate::types::ItemSection> = db_sections
96 .iter()
97 .map(crate::types::ItemSection::from)
98 .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(db): State<PgPool>,
112 AuthUser(session_user): AuthUser,
113 Path(id): Path<String>,
114 ) -> Result<impl IntoResponse> {
115 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
116 let item_id = db_item.id;
117 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
118 let item = build_item_view(&db_item, &item_tags);
119
120 let db_license_keys = if db_item.enable_license_keys {
121 db::license_keys::get_license_keys_by_item(&db, item_id).await?
122 } else {
123 vec![]
124 };
125 let license_keys: Vec<LicenseKeyRow> = db_license_keys
126 .into_iter()
127 .map(LicenseKeyRow::from)
128 .collect();
129
130 let db_promo_codes = db::promo_codes::get_promo_codes_by_item(&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(db): State<PgPool>,
146 AuthUser(session_user): AuthUser,
147 Path(id): Path<String>,
148 ) -> Result<impl IntoResponse> {
149 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
150 let item_id = db_item.id;
151 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
152 let item = build_item_view(&db_item, &item_tags);
153
154 let db_versions = db::versions::get_versions_by_item(&db, item_id).await?;
155 let versions: Vec<Version> = db_versions.iter().map(Version::from_db).collect();
156
157 Ok(ItemFilesTabTemplate { item, versions })
158 }
159
160 /// Item embed tab: copy-paste embed codes.
161 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_embed")]
162 pub(in crate::routes::pages::dashboard) async fn item_tab_embed(
163 State(db): State<PgPool>,
164 State(config): State<Config>,
165 AuthUser(session_user): AuthUser,
166 Path(id): Path<String>,
167 ) -> Result<impl IntoResponse> {
168 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
169 let item_tags = db::tags::get_tags_for_item(&db, db_item.id).await?;
170 let is_audio = db_item.audio_s3_key.is_some();
171 let item = build_item_view(&db_item, &item_tags);
172
173 Ok(ItemEmbedTabTemplate {
174 item,
175 host_url: config.host_url.clone(),
176 is_audio,
177 })
178 }
179
180 /// Item sales tab: transaction history with refund buttons.
181 #[tracing::instrument(skip_all, name = "item_tabs::item_tab_sales")]
182 pub(in crate::routes::pages::dashboard) async fn item_tab_sales(
183 State(db): State<PgPool>,
184 AuthUser(session_user): AuthUser,
185 Path(id): Path<String>,
186 ) -> Result<impl IntoResponse> {
187 let (db_item, _db_project) = resolve_owned_item(&db, session_user.id, &id).await?;
188 let item_id = db_item.id;
189 let item_tags = db::tags::get_tags_for_item(&db, item_id).await?;
190 let item = build_item_view(&db_item, &item_tags);
191
192 let sales = db::transactions::get_sales_by_item(&db, item_id, session_user.id).await?;
193 let rows: Vec<SaleRow> = sales
194 .iter()
195 .map(|tx| {
196 let buyer_display = tx
197 .guest_email
198 .clone()
199 .or_else(|| tx.buyer_id.map(|_| "Registered user".to_string()))
200 .unwrap_or_else(|| "Unknown".to_string());
201 let cents = tx.amount_cents.as_i64();
202 SaleRow {
203 transaction_id: tx.id.to_string(),
204 buyer: buyer_display,
205 amount_display: if cents == 0 {
206 "Free".to_string()
207 } else {
208 format!("${}.{:02}", cents / 100, cents % 100)
209 },
210 status: tx.status.to_string(),
211 date: tx.created_at.format("%Y-%m-%d %H:%M").to_string(),
212 refundable: tx.status == db::TransactionStatus::Completed
213 && tx.stripe_payment_intent_id.is_some()
214 && cents > 0,
215 }
216 })
217 .collect();
218
219 Ok(ItemSalesTabTemplate { item, sales: rows })
220 }
221