Skip to main content

max / makenotwork

v0.3.12: Bundle item type with batch file upload, delete-item redirect Add Bundle as a first-class item type: migration 048 (bundle_items table), db/bundles.rs, wizard type card, bundle content step with two sections (batch file upload + existing item picker). Batch upload creates items sequentially via existing APIs (create_item → presign → S3 → confirm), auto-detecting audio vs digital from file extension. Delete-item now redirects to the project dashboard instead of showing a toast on the stale item page. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-28 20:42 UTC
Commit: bee5f457be120e2a3679590b5cdd175c506d9850
Parent: c78f642
24 files changed, +880 insertions, -40 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.11"
3 + version = "0.3.12"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -0,0 +1,14 @@
1 + -- Items can be unlisted (hidden from project page, accessible via bundle or direct URL)
2 + ALTER TABLE items ADD COLUMN listed BOOLEAN NOT NULL DEFAULT true;
3 +
4 + -- Bundle contents: which items are included in a bundle-type item
5 + CREATE TABLE bundle_items (
6 + bundle_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
7 + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
8 + sort_order INT NOT NULL DEFAULT 0,
9 + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10 + PRIMARY KEY (bundle_id, item_id),
11 + CHECK (bundle_id != item_id)
12 + );
13 +
14 + CREATE INDEX idx_bundle_items_item ON bundle_items(item_id);
@@ -0,0 +1,189 @@
1 + //! Bundle item management: linking items into bundles and checking bundle-based access.
2 +
3 + use sqlx::PgPool;
4 +
5 + use super::models::DbItem;
6 + use super::{ItemId, ProjectId, UserId};
7 + use crate::error::Result;
8 +
9 + /// Add an item to a bundle at the given sort position.
10 + ///
11 + /// Uses `ON CONFLICT DO NOTHING` so re-adding is idempotent.
12 + pub async fn add_item_to_bundle(
13 + pool: &PgPool,
14 + bundle_id: ItemId,
15 + item_id: ItemId,
16 + sort_order: i32,
17 + ) -> Result<()> {
18 + sqlx::query(
19 + r#"
20 + INSERT INTO bundle_items (bundle_id, item_id, sort_order)
21 + VALUES ($1, $2, $3)
22 + ON CONFLICT (bundle_id, item_id) DO UPDATE SET sort_order = $3
23 + "#,
24 + )
25 + .bind(bundle_id)
26 + .bind(item_id)
27 + .bind(sort_order)
28 + .execute(pool)
29 + .await?;
30 +
31 + Ok(())
32 + }
33 +
34 + /// Remove an item from a bundle.
35 + pub async fn remove_item_from_bundle(
36 + pool: &PgPool,
37 + bundle_id: ItemId,
38 + item_id: ItemId,
39 + ) -> Result<()> {
40 + sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1 AND item_id = $2")
41 + .bind(bundle_id)
42 + .bind(item_id)
43 + .execute(pool)
44 + .await?;
45 +
46 + Ok(())
47 + }
48 +
49 + /// Get all items included in a bundle, ordered by sort_order.
50 + pub async fn get_bundle_items(pool: &PgPool, bundle_id: ItemId) -> Result<Vec<DbItem>> {
51 + let items = sqlx::query_as::<_, DbItem>(
52 + r#"
53 + SELECT i.* FROM items i
54 + JOIN bundle_items bi ON i.id = bi.item_id
55 + WHERE bi.bundle_id = $1
56 + ORDER BY bi.sort_order, bi.added_at
57 + LIMIT 100
58 + "#,
59 + )
60 + .bind(bundle_id)
61 + .fetch_all(pool)
62 + .await?;
63 +
64 + Ok(items)
65 + }
66 +
67 + /// Get the IDs of all bundles that contain a given item.
68 + pub async fn get_bundles_containing_item(
69 + pool: &PgPool,
70 + item_id: ItemId,
71 + ) -> Result<Vec<ItemId>> {
72 + let ids: Vec<ItemId> = sqlx::query_scalar(
73 + "SELECT bundle_id FROM bundle_items WHERE item_id = $1",
74 + )
75 + .bind(item_id)
76 + .fetch_all(pool)
77 + .await?;
78 +
79 + Ok(ids)
80 + }
81 +
82 + /// Check whether a user has access to an item through any purchased bundle.
83 + ///
84 + /// Returns true if the user has a completed transaction for any bundle
85 + /// that contains this item.
86 + pub async fn has_access_via_bundle(
87 + pool: &PgPool,
88 + user_id: UserId,
89 + item_id: ItemId,
90 + ) -> Result<bool> {
91 + let exists: bool = sqlx::query_scalar(
92 + r#"
93 + SELECT EXISTS(
94 + SELECT 1 FROM bundle_items bi
95 + JOIN transactions t ON t.item_id = bi.bundle_id
96 + WHERE bi.item_id = $1
97 + AND t.buyer_id = $2
98 + AND t.status = 'completed'
99 + )
100 + "#,
101 + )
102 + .bind(item_id)
103 + .bind(user_id)
104 + .fetch_one(pool)
105 + .await?;
106 +
107 + Ok(exists)
108 + }
109 +
110 + /// Get all non-bundle items in a project (candidates for inclusion in a bundle).
111 + ///
112 + /// Excludes the bundle itself (by `exclude_bundle_id`) and any items that are
113 + /// already bundles (to prevent nesting).
114 + pub async fn get_bundleable_items(
115 + pool: &PgPool,
116 + project_id: ProjectId,
117 + exclude_bundle_id: Option<ItemId>,
118 + ) -> Result<Vec<DbItem>> {
119 + let items = sqlx::query_as::<_, DbItem>(
120 + r#"
121 + SELECT * FROM items
122 + WHERE project_id = $1
123 + AND item_type != 'bundle'
124 + AND ($2::UUID IS NULL OR id != $2)
125 + ORDER BY sort_order, created_at DESC
126 + LIMIT 500
127 + "#,
128 + )
129 + .bind(project_id)
130 + .bind(exclude_bundle_id)
131 + .fetch_all(pool)
132 + .await?;
133 +
134 + Ok(items)
135 + }
136 +
137 + /// Replace the full set of items in a bundle (transactional).
138 + ///
139 + /// Deletes all existing bundle_items rows for the bundle and inserts the new set.
140 + /// `item_ids` is an ordered list — sort_order is derived from position.
141 + pub async fn set_bundle_items(
142 + pool: &PgPool,
143 + bundle_id: ItemId,
144 + item_ids: &[ItemId],
145 + ) -> Result<()> {
146 + let mut tx = pool.begin().await?;
147 +
148 + sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1")
149 + .bind(bundle_id)
150 + .execute(&mut *tx)
151 + .await?;
152 +
153 + for (i, item_id) in item_ids.iter().enumerate() {
154 + sqlx::query(
155 + "INSERT INTO bundle_items (bundle_id, item_id, sort_order) VALUES ($1, $2, $3)",
156 + )
157 + .bind(bundle_id)
158 + .bind(item_id)
159 + .bind(i as i32)
160 + .execute(&mut *tx)
161 + .await?;
162 + }
163 +
164 + tx.commit().await?;
165 + Ok(())
166 + }
167 +
168 + /// Count how many items are in a bundle.
169 + pub async fn get_bundle_item_count(pool: &PgPool, bundle_id: ItemId) -> Result<i64> {
170 + let count: i64 = sqlx::query_scalar(
171 + "SELECT COUNT(*) FROM bundle_items WHERE bundle_id = $1",
172 + )
173 + .bind(bundle_id)
174 + .fetch_one(pool)
175 + .await?;
176 +
177 + Ok(count)
178 + }
179 +
180 + /// Set the `listed` flag on an item.
181 + pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> {
182 + sqlx::query("UPDATE items SET listed = $2 WHERE id = $1")
183 + .bind(item_id)
184 + .bind(listed)
185 + .execute(pool)
186 + .await?;
187 +
188 + Ok(())
189 + }
@@ -300,6 +300,7 @@ pub enum ItemType {
300 300 Course,
301 301 Template,
302 302 Digital,
303 + Bundle,
303 304 }
304 305
305 306 impl_str_enum!(ItemType {
@@ -313,6 +314,7 @@ impl_str_enum!(ItemType {
313 314 Course => "course",
314 315 Template => "template",
315 316 Digital => "digital",
317 + Bundle => "bundle",
316 318 });
317 319
318 320 impl ItemType {
@@ -329,6 +331,7 @@ impl ItemType {
329 331 Self::Course => "Course",
330 332 Self::Template => "Template",
331 333 Self::Digital => "Digital",
334 + Self::Bundle => "Bundle",
332 335 }
333 336 }
334 337
@@ -337,11 +340,13 @@ impl ItemType {
337 340 /// Determines what the content step looks like:
338 341 /// - `"text"` → Markdown editor
339 342 /// - `"audio"` → Audio file upload
343 + /// - `"bundle"` → Item picker for bundle contents
340 344 /// - `"file"` → Generic file upload
341 345 pub fn wizard_group(&self) -> &'static str {
342 346 match self {
343 347 Self::Text => "text",
344 348 Self::Audio => "audio",
349 + Self::Bundle => "bundle",
345 350 _ => "file",
346 351 }
347 352 }
@@ -590,7 +595,7 @@ impl ProjectFeature {
590 595 .filter(|(value, _, _)| {
591 596 value
592 597 .parse::<ItemType>()
593 - .map(|t| allowed.contains(&t))
598 + .map(|t| t == ItemType::Bundle || allowed.contains(&t))
594 599 .unwrap_or(false)
595 600 })
596 601 .copied()
@@ -610,6 +615,7 @@ impl ProjectFeature {
610 615 ("preset", "Preset Pack", "Synth presets, effect chains"),
611 616 ("template", "Template", "Design templates, starter kits"),
612 617 ("image", "Image", "Photos, artwork, graphics"),
618 + ("bundle", "Bundle", "Collection of other items"),
613 619 ]
614 620 }
615 621
@@ -636,6 +642,7 @@ impl ProjectFeature {
636 642 let (label, desc) = match group {
637 643 "text" => ("Text", "Write in the editor"),
638 644 "audio" => ("Audio", "Upload audio files"),
645 + "bundle" => ("Bundle", "Collection of other items"),
639 646 _ => ("File", "Upload any file"),
640 647 };
641 648 cards.push((*value, label, desc));
@@ -821,6 +828,8 @@ mod tests {
821 828 fn item_type_round_trip() {
822 829 assert_eq!(ItemType::Audio.to_string(), "audio");
823 830 assert_eq!("plugin".parse::<ItemType>().unwrap(), ItemType::Plugin);
831 + assert_eq!(ItemType::Bundle.to_string(), "bundle");
832 + assert_eq!("bundle".parse::<ItemType>().unwrap(), ItemType::Bundle);
824 833 }
825 834
826 835 #[test]
@@ -1008,6 +1017,7 @@ mod tests {
1008 1017 assert!(values.contains(&"audio"));
1009 1018 assert!(values.contains(&"sample"));
1010 1019 assert!(values.contains(&"preset"));
1020 + assert!(values.contains(&"bundle")); // Bundle always included
1011 1021 assert!(!values.contains(&"text"));
1012 1022 assert!(!values.contains(&"digital"));
1013 1023 }
@@ -1018,20 +1028,21 @@ mod tests {
1018 1028 let values: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1019 1029 assert!(values.contains(&"audio"));
1020 1030 assert!(values.contains(&"text"));
1031 + assert!(values.contains(&"bundle")); // Bundle always included
1021 1032 assert!(!values.contains(&"digital"));
1022 1033 }
1023 1034
1024 1035 #[test]
1025 1036 fn project_feature_allowed_cards_empty_features_shows_all() {
1026 1037 let cards = ProjectFeature::allowed_item_type_cards(&[]);
1027 - assert_eq!(cards.len(), ProjectFeature::all_item_type_cards().len());
1038 + assert_eq!(cards.len(), 11); // 10 content types + bundle
1028 1039 }
1029 1040
1030 1041 #[test]
1031 1042 fn project_feature_allowed_cards_non_content_features_shows_all() {
1032 1043 let cards = ProjectFeature::allowed_item_type_cards(&["blog".into(), "subscriptions".into()]);
1033 1044 // Blog and subscriptions don't gate item types, so all should be shown
1034 - assert_eq!(cards.len(), ProjectFeature::all_item_type_cards().len());
1045 + assert_eq!(cards.len(), 11); // 10 content types + bundle
1035 1046 }
1036 1047
1037 1048 #[test]
@@ -1152,45 +1163,55 @@ mod tests {
1152 1163 }
1153 1164 }
1154 1165
1166 + #[test]
1167 + fn wizard_group_bundle() {
1168 + assert_eq!(ItemType::Bundle.wizard_group(), "bundle");
1169 + }
1170 +
1155 1171 // ── ProjectFeature::wizard_type_cards ──
1156 1172
1157 1173 #[test]
1158 - fn wizard_cards_text_only_one_group() {
1174 + fn wizard_cards_text_only_two_groups() {
1175 + // Text + bundle (bundle always included)
1159 1176 let cards = ProjectFeature::wizard_type_cards(&["text".into()]);
1160 - assert_eq!(cards.len(), 1);
1161 - assert_eq!(cards[0].0, "text");
1177 + assert_eq!(cards.len(), 2);
1178 + let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1179 + assert!(groups.contains(&"text"));
1180 + assert!(groups.contains(&"bundle"));
1162 1181 }
1163 1182
1164 1183 #[test]
1165 - fn wizard_cards_downloads_only_one_group() {
1166 - // All download types are in the "file" group → single card
1184 + fn wizard_cards_downloads_two_groups() {
1185 + // All download types are in the "file" group + bundle → 2 cards
1167 1186 let cards = ProjectFeature::wizard_type_cards(&["downloads".into()]);
1168 - assert_eq!(cards.len(), 1);
1169 - assert_eq!(cards[0].0, "digital"); // first type in file group
1187 + assert_eq!(cards.len(), 2);
1188 + let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1189 + assert!(groups.contains(&"digital")); // first type in file group
1190 + assert!(groups.contains(&"bundle"));
1170 1191 }
1171 1192
1172 1193 #[test]
1173 - fn wizard_cards_audio_feature_two_groups() {
1174 - // Audio feature allows audio (audio group) + sample, preset (file group)
1194 + fn wizard_cards_audio_feature_three_groups() {
1195 + // Audio feature allows audio (audio group) + sample, preset (file group) + bundle
1175 1196 let cards = ProjectFeature::wizard_type_cards(&["audio".into()]);
1176 - assert_eq!(cards.len(), 2);
1197 + assert_eq!(cards.len(), 3);
1177 1198 let groups: Vec<&str> = cards.iter().map(|(v, _, _)| *v).collect();
1178 1199 assert!(groups.contains(&"audio"));
1179 - // sample is the first file-group type among audio feature's allowed types
1180 - assert!(groups.contains(&"sample"));
1200 + assert!(groups.contains(&"sample")); // first file-group type
1201 + assert!(groups.contains(&"bundle"));
1181 1202 }
1182 1203
1183 1204 #[test]
1184 - fn wizard_cards_text_and_audio_three_groups() {
1205 + fn wizard_cards_text_and_audio_four_groups() {
1185 1206 let cards =
1186 1207 ProjectFeature::wizard_type_cards(&["text".into(), "audio".into()]);
1187 - assert_eq!(cards.len(), 3);
1208 + assert_eq!(cards.len(), 4); // text, audio, file, bundle
1188 1209 }
1189 1210
1190 1211 #[test]
1191 - fn wizard_cards_empty_features_all_three_groups() {
1192 - // No content features → all types → 3 wizard groups
1212 + fn wizard_cards_empty_features_all_four_groups() {
1213 + // No content features → all types → 4 wizard groups (text, audio, file, bundle)
1193 1214 let cards = ProjectFeature::wizard_type_cards(&[]);
1194 - assert_eq!(cards.len(), 3);
1215 + assert_eq!(cards.len(), 4);
1195 1216 }
1196 1217 }
@@ -137,7 +137,7 @@ pub async fn get_items_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbI
137 137 /// Capped at 500 as a safety limit.
138 138 pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
139 139 let items = sqlx::query_as::<_, DbItem>(
140 - "SELECT * FROM items WHERE project_id = $1 AND is_public = true ORDER BY sort_order, created_at DESC LIMIT 500",
140 + "SELECT * FROM items WHERE project_id = $1 AND is_public = true AND listed = true ORDER BY sort_order, created_at DESC LIMIT 500",
141 141 )
142 142 .bind(project_id)
143 143 .fetch_all(pool)
@@ -284,7 +284,7 @@ pub async fn get_public_items_by_user(pool: &PgPool, user_id: UserId) -> Result<
284 284 r#"
285 285 SELECT i.* FROM items i
286 286 JOIN projects p ON i.project_id = p.id
287 - WHERE p.user_id = $1 AND p.is_public = true AND i.is_public = true
287 + WHERE p.user_id = $1 AND p.is_public = true AND i.is_public = true AND i.listed = true
288 288 ORDER BY i.created_at DESC
289 289 LIMIT 50
290 290 "#,
@@ -50,6 +50,7 @@ pub(crate) mod creator_tiers;
50 50 pub(crate) mod mailing_lists;
51 51 pub mod custom_domains;
52 52 pub mod patches;
53 + pub mod bundles;
53 54
54 55 pub use id_types::*;
55 56 pub use validated_types::*;
@@ -308,6 +308,8 @@ pub struct DbItem {
308 308 pub cover_file_size_bytes: Option<i64>,
309 309 /// URL-safe slug unique per project (for custom domain pretty URLs).
310 310 pub slug: String,
311 + /// Whether this item appears on the project page (unlisted items are bundle-only).
312 + pub listed: bool,
311 313 }
312 314
313 315 /// Content-type-specific data extracted from a `DbItem`.
@@ -1991,6 +1993,7 @@ mod tests {
1991 1993 audio_file_size_bytes: None,
1992 1994 cover_file_size_bytes: None,
1993 1995 slug: "test".to_string(),
1996 + listed: true,
1994 1997 }
1995 1998 }
1996 1999
@@ -664,6 +664,7 @@ mod tests {
664 664 audio_file_size_bytes: None,
665 665 cover_file_size_bytes: None,
666 666 slug: "test".to_string(),
667 + listed: true,
667 668 }
668 669 }
669 670
@@ -222,9 +222,9 @@ pub(super) async fn delete_item(
222 222 State(state): State<AppState>,
223 223 AuthUser(user): AuthUser,
224 224 Path(id): Path<ItemId>,
225 - ) -> Result<impl IntoResponse> {
225 + ) -> Result<Response> {
226 226 user.check_not_suspended()?;
227 - let (item, _) = verify_item_ownership(&state, id, user.id).await?;
227 + let (item, project) = verify_item_ownership(&state, id, user.id).await?;
228 228
229 229 // Calculate total file bytes to decrement from storage counter
230 230 let file_sizes = db::items::get_item_file_sizes(&state.db, id).await?;
@@ -241,7 +241,14 @@ pub(super) async fn delete_item(
241 241 db::creator_tiers::decrement_storage_used(&state.db, user.id, total_bytes).await?;
242 242 }
243 243
244 - Ok(htmx_toast_response("Item deleted", "success"))
244 + let mut response = Response::new(axum::body::Body::empty());
245 + response.headers_mut().insert(
246 + "HX-Redirect",
247 + format!("/dashboard/project/{}", project.slug)
248 + .parse()
249 + .expect("redirect path is valid"),
250 + );
251 + Ok(response)
245 252 }
246 253
247 254 /// Duplicate an item and its metadata, creating a new draft.
@@ -285,6 +285,20 @@ pub(super) async fn dashboard_item(
285 285 let license_keys: Vec<LicenseKeyRow> = db_license_keys.into_iter().map(LicenseKeyRow::from).collect();
286 286 let promo_codes: Vec<PromoCodeRow> = db_promo_codes.into_iter().map(PromoCodeRow::from).collect();
287 287
288 + // Load bundle child items if this is a bundle
289 + let bundle_items = if db_item.item_type == db::ItemType::Bundle {
290 + let children = db::bundles::get_bundle_items(&state.db, item_id).await?;
291 + children
292 + .iter()
293 + .map(|child| {
294 + let child_tags = vec![]; // Tags not needed for dashboard bundle list
295 + Item::from_db_list(child, &child_tags, false, true)
296 + })
297 + .collect()
298 + } else {
299 + vec![]
300 + };
301 +
288 302 Ok(DashboardItemTemplate {
289 303 csrf_token,
290 304 session_user: Some(session_user),
@@ -294,6 +308,7 @@ pub(super) async fn dashboard_item(
294 308 license_keys,
295 309 promo_codes,
296 310 project_labels,
311 + bundle_items,
297 312 })
298 313 }
299 314
@@ -310,11 +310,46 @@ async fn save_content(
310 310 item: &db::DbItem,
311 311 form: &HashMap<String, String>,
312 312 ) -> Result<()> {
313 - // Text items: save body directly
314 - if item.item_type == ItemType::Text
315 - && let Some(body) = form.get("body")
316 - {
317 - db::items::update_item_text(&state.db, item.id, body).await?;
313 + if item.item_type == ItemType::Text {
314 + // Text items: save body directly
315 + if let Some(body) = form.get("body") {
316 + db::items::update_item_text(&state.db, item.id, body).await?;
317 + }
318 + } else if item.item_type == ItemType::Bundle {
319 + // Bundle items: parse selected item IDs and unlisted flags
320 + let bundle_ids: Vec<ItemId> = form
321 + .get("bundle_item_ids")
322 + .map(|s| {
323 + s.split(',')
324 + .filter(|v| !v.is_empty())
325 + .filter_map(|v| v.parse().ok())
326 + .collect()
327 + })
328 + .unwrap_or_default();
329 +
330 + let unlisted_ids: Vec<ItemId> = form
331 + .get("unlisted_item_ids")
332 + .map(|s| {
333 + s.split(',')
334 + .filter(|v| !v.is_empty())
335 + .filter_map(|v| v.parse().ok())
336 + .collect()
337 + })
338 + .unwrap_or_default();
339 +
340 + // Set bundle contents (replaces all existing)
341 + db::bundles::set_bundle_items(&state.db, item.id, &bundle_ids).await?;
342 +
343 + // Update listed status for all bundleable items in this project
344 + let all_bundleable =
345 + db::bundles::get_bundleable_items(&state.db, item.project_id, Some(item.id)).await?;
346 + for bi in &all_bundleable {
347 + let should_be_unlisted = unlisted_ids.contains(&bi.id);
348 + if bi.listed == should_be_unlisted {
349 + // listed=true but should be unlisted, or listed=false but shouldn't be
350 + db::bundles::set_item_listed(&state.db, bi.id, !should_be_unlisted).await?;
351 + }
352 + }
318 353 }
319 354 // Audio/file items: content uploaded via presign flow (client-side S3)
320 355 Ok(())
@@ -544,12 +579,45 @@ async fn render_step(
544 579
545 580 "content" => {
546 581 let content_template = item.item_type.to_string();
582 +
583 + // Load bundle data if this is a bundle item
584 + let (bundleable_items, selected_bundle_ids, unlisted_ids) =
585 + if item.item_type == ItemType::Bundle {
586 + let bundleable =
587 + db::bundles::get_bundleable_items(&state.db, project.id, Some(item.id))
588 + .await?;
589 + let current = db::bundles::get_bundle_items(&state.db, item.id).await?;
590 + let selected: Vec<String> =
591 + current.iter().map(|i| i.id.to_string()).collect();
592 + let unlisted: Vec<String> = current
593 + .iter()
594 + .chain(bundleable.iter())
595 + .filter(|i| !i.listed)
596 + .map(|i| i.id.to_string())
597 + .collect();
598 + let bi: Vec<BundleableItem> = bundleable
599 + .iter()
600 + .map(|i| BundleableItem {
601 + id: i.id.to_string(),
602 + title: i.title.clone(),
603 + item_type: i.item_type.label().to_string(),
604 + })
605 + .collect();
606 + (bi, selected, unlisted)
607 + } else {
608 + (vec![], vec![], vec![])
609 + };
610 +
547 611 Ok(WizardItemContentTemplate {
548 612 nav,
549 613 project_slug,
614 + project_id: project.id.to_string(),
550 615 item_id,
551 616 item_type: content_template,
552 617 body: item.body.clone().unwrap_or_default(),
618 + bundleable_items,
619 + selected_bundle_ids,
620 + unlisted_ids,
553 621 }
554 622 .into_response())
555 623 }
@@ -638,7 +706,12 @@ async fn render_step(
638 706 tag_names,
639 707 enable_license_keys: item.enable_license_keys,
640 708 has_content: item.body.is_some()
641 - || item.audio_s3_key.is_some(),
709 + || item.audio_s3_key.is_some()
710 + || (item.item_type == ItemType::Bundle
711 + && db::bundles::get_bundle_item_count(&state.db, item.id)
712 + .await
713 + .unwrap_or(0)
714 + > 0),
642 715 is_public: item.is_public,
643 716 }
644 717 .into_response())
@@ -175,7 +175,11 @@ pub(crate) async fn render_project_page(
175 175 let can_access = item_pricing.can_access(&ctx);
176 176 let is_free = item_pricing.is_free();
177 177 let item_tags = tags_map.get(&i.id).map(|v| v.as_slice()).unwrap_or(&[]);
178 - items.push(Item::from_db_list(i, item_tags, is_free, can_access));
178 + let mut item = Item::from_db_list(i, item_tags, is_free, can_access);
179 + if i.item_type == ItemType::Bundle {
180 + item.bundle_item_count = db::bundles::get_bundle_item_count(&state.db, i.id).await?;
181 + }
182 + items.push(item);
179 183 }
180 184
181 185 let follower_count = db::follows::get_follower_count(&state.db, FollowTargetType::Project, db_project.id.into()).await?;
@@ -292,9 +296,43 @@ pub(crate) async fn render_item_page(
292 296 has_purchased: in_library,
293 297 has_active_subscription: has_item_sub,
294 298 };
295 - let has_access = item_pricing.can_access(&ctx);
299 + let mut has_access = item_pricing.can_access(&ctx);
296 300 let is_free = item_pricing.is_free();
297 301
302 + // Bundle access: user may have purchased a bundle containing this item
303 + if !has_access {
304 + if let Some(ref user) = maybe_user {
305 + if db::bundles::has_access_via_bundle(&state.db, user.id, db_item.id).await? {
306 + has_access = true;
307 + }
308 + }
309 + }
310 +
311 + // For unlisted items, load the bundles that contain them (for "Available in" display)
312 + let containing_bundle_ids = if !db_item.listed {
313 + db::bundles::get_bundles_containing_item(&state.db, db_item.id).await?
314 + } else {
315 + vec![]
316 + };
317 + let containing_bundles: Vec<db::DbItem> = {
318 + let mut bundles = Vec::new();
319 + for bid in &containing_bundle_ids {
320 + if let Some(b) = db::items::get_item_by_id(&state.db, *bid).await? {
321 + if b.is_public {
322 + bundles.push(b);
323 + }
324 + }
325 + }
326 + bundles
327 + };
328 +
329 + // For bundle-type items, load the child items
330 + let bundle_child_items = if db_item.item_type == ItemType::Bundle {
331 + db::bundles::get_bundle_items(&state.db, db_item.id).await?
332 + } else {
333 + vec![]
334 + };
335 +
298 336 let item_tags = db::tags::get_tags_for_item(&state.db, db_item.id).await?;
299 337 let item = Item::from_db_detail(db_item, &item_tags, body_html.clone(), reading_time.clone(), is_free, has_access);
300 338
@@ -387,6 +425,18 @@ pub(crate) async fn render_item_page(
387 425 let (discussion_url, discussion_count) =
388 426 fetch_discussion_info(state, db_item.mt_thread_id, &project_slug_str, "items").await;
389 427
428 + // Convert bundle child items to view models
429 + let bundle_item_views: Vec<Item> = bundle_child_items.iter().map(|child| {
430 + let child_tags = Vec::new(); // Tags not needed for bundle child list display
431 + Item::from_db_list(child, &child_tags, child.price_cents == 0, false)
432 + }).collect();
433 +
434 + // Convert containing bundles to view models
435 + let containing_bundle_views: Vec<Item> = containing_bundles.iter().map(|b| {
436 + let b_tags = Vec::new();
437 + Item::from_db_list(b, &b_tags, b.price_cents == 0, false)
438 + }).collect();
439 +
390 440 Ok(ItemTemplate {
391 441 csrf_token,
392 442 session_user: maybe_user,
@@ -398,6 +448,8 @@ pub(crate) async fn render_item_page(
398 448 host_url: state.config.host_url.clone(),
399 449 discussion_url,
400 450 discussion_count,
451 + bundle_items: bundle_item_views,
452 + containing_bundles: containing_bundle_views,
401 453 }.into_response())
402 454 }
403 455
@@ -397,7 +397,10 @@ async fn stream_url(
397 397 has_active_subscription: db::subscriptions::has_active_subscription_to_item(&state.db, user.id, item_id).await?,
398 398 };
399 399 if !item_pricing.can_access(&ctx) {
400 - return Err(AppError::Forbidden);
400 + // Check bundle access as fallback
401 + if !db::bundles::has_access_via_bundle(&state.db, user.id, item_id).await? {
402 + return Err(AppError::Forbidden);
403 + }
401 404 }
402 405 }
403 406
@@ -602,7 +605,10 @@ async fn version_download(
602 605 has_active_subscription: db::subscriptions::has_active_subscription_to_item(&state.db, user.id, version.item_id).await?,
603 606 };
604 607 if !item_pricing.can_access(&ctx) {
605 - return Err(AppError::Forbidden);
608 + // Check bundle access as fallback
609 + if !db::bundles::has_access_via_bundle(&state.db, user.id, version.item_id).await? {
610 + return Err(AppError::Forbidden);
611 + }
606 612 }
607 613 }
608 614
@@ -49,6 +49,11 @@ pub(super) async fn create_checkout(
49 49 return Err(AppError::BadRequest("This item is not available for purchase".to_string()));
50 50 }
51 51
52 + // Unlisted items can only be obtained through their bundle
53 + if !item.listed {
54 + return Err(AppError::BadRequest("This item is only available as part of a bundle".to_string()));
55 + }
56 +
52 57 // Free items don't need checkout
53 58 let item_pricing = pricing::for_item(&item);
54 59 if item_pricing.checkout_type() == CheckoutType::None {
@@ -216,6 +221,10 @@ pub(super) async fn create_checkout(
216 221 };
217 222
218 223 if claimed {
224 + // Grant access to bundle child items (if this is a bundle)
225 + if item.item_type == db::ItemType::Bundle {
226 + grant_bundle_items(&state, item_uuid, user.id, seller_id).await;
227 + }
219 228
220 229 // Clear any prior contact revocation if fan is re-sharing
221 230 if form.share_contact {
@@ -736,3 +745,53 @@ pub(super) async fn checkout_cancel(
736 745
737 746 Redirect::to(&redirect_url)
738 747 }
748 +
749 + /// Grant access to all child items of a purchased bundle.
750 + ///
751 + /// For each child item, creates a completed $0 transaction (idempotent via
752 + /// ON CONFLICT DO NOTHING). Does NOT increment child item sales_count —
753 + /// the bundle sale is what counts.
754 + pub(crate) async fn grant_bundle_items(
755 + state: &AppState,
756 + bundle_id: db::ItemId,
757 + buyer_id: db::UserId,
758 + seller_id: db::UserId,
759 + ) {
760 + let child_items = match db::bundles::get_bundle_items(&state.db, bundle_id).await {
761 + Ok(items) => items,
762 + Err(e) => {
763 + tracing::error!(bundle_id = %bundle_id, error = ?e, "failed to load bundle items for granting");
764 + return;
765 + }
766 + };
767 +
768 + let seller = match db::users::get_user_by_id(&state.db, seller_id).await {
769 + Ok(Some(u)) => u,
770 + _ => return,
771 + };
772 +
773 + for child in &child_items {
774 + let claim = db::transactions::ClaimParams {
775 + buyer_id,
776 + item_id: child.id,
777 + seller_id,
778 + item_title: &child.title,
779 + seller_username: &seller.username,
780 + share_contact: false,
781 + };
782 + // Idempotent: ON CONFLICT DO NOTHING if already claimed
783 + if let Err(e) = db::transactions::claim_free_item(&state.db, &claim).await {
784 + tracing::warn!(
785 + child_item_id = %child.id, bundle_id = %bundle_id,
786 + error = ?e, "failed to grant bundle child item"
787 + );
788 + }
789 + // Deliberately NOT incrementing sales_count for child items
790 + }
791 +
792 + tracing::info!(
793 + bundle_id = %bundle_id, buyer_id = %buyer_id,
794 + child_count = child_items.len(),
795 + "granted bundle child items"
796 + );
797 + }
@@ -56,6 +56,13 @@ pub(super) async fn handle_purchase_checkout_completed(
56 56
57 57 // --- Secondary effects below (outside transaction) ---
58 58
59 + // Grant access to bundle child items (if this is a bundle)
60 + if let Ok(Some(purchased_item)) = db::items::get_item_by_id(&state.db, item_id).await {
61 + if purchased_item.item_type == db::ItemType::Bundle {
62 + crate::routes::stripe::checkout::grant_bundle_items(state, item_id, buyer_id, seller_id).await;
63 + }
64 + }
65 +
59 66 if tx.share_contact {
60 67 db::transactions::clear_contact_revocation(&state.db, buyer_id, seller_id).await?;
61 68 }
@@ -70,6 +70,8 @@ pub struct DashboardItemTemplate {
70 70 pub promo_codes: Vec<PromoCodeRow>,
71 71 /// Project labels (display names only) for publish reminder.
72 72 pub project_labels: Vec<String>,
73 + /// Child items in this bundle (empty for non-bundle items).
74 + pub bundle_items: Vec<Item>,
73 75 }
74 76
75 77 // ============================================================================
@@ -313,9 +315,23 @@ pub struct WizardItemDetailsTemplate {
313 315 pub struct WizardItemContentTemplate {
314 316 pub nav: Vec<StepNavItem>,
315 317 pub project_slug: String,
318 + pub project_id: String,
316 319 pub item_id: String,
317 320 pub item_type: String,
318 321 pub body: String,
322 + /// Non-bundle items in the project available for inclusion.
323 + pub bundleable_items: Vec<BundleableItem>,
324 + /// IDs of items already selected for this bundle.
325 + pub selected_bundle_ids: Vec<String>,
326 + /// IDs of items currently marked unlisted.
327 + pub unlisted_ids: Vec<String>,
328 + }
329 +
330 + /// Lightweight item info for the bundle item picker.
331 + pub struct BundleableItem {
332 + pub id: String,
333 + pub title: String,
334 + pub item_type: String,
319 335 }
320 336
321 337 #[derive(Template)]
@@ -232,6 +232,7 @@ pub struct ProjectPaywallTemplate {
232 232 /// Public item detail page.
233 233 #[derive(Template)]
234 234 #[template(path = "pages/item.html")]
235 + #[allow(dead_code)] // Fields used by Askama template
235 236 pub struct ItemTemplate {
236 237 pub csrf_token: CsrfTokenOption,
237 238 pub session_user: Option<SessionUser>,
@@ -246,6 +247,10 @@ pub struct ItemTemplate {
246 247 pub discussion_url: Option<String>,
247 248 /// Number of posts in the linked discussion thread.
248 249 pub discussion_count: Option<i64>,
250 + /// Child items for bundle-type items (empty for non-bundles).
251 + pub bundle_items: Vec<Item>,
252 + /// Bundles containing this item (for unlisted items, to show "Available in" links).
253 + pub containing_bundles: Vec<Item>,
249 254 }
250 255
251 256 /// Blog/article reader view.
@@ -254,6 +254,8 @@ impl Item {
254 254 pwyw_min_cents: db_item.pwyw_min_cents,
255 255 publish_at: db_item.publish_at.map(|d| d.format(DATE_FMT_DATETIME_UTC).to_string()),
256 256 is_public: db_item.is_public,
257 + listed: db_item.listed,
258 + bundle_item_count: 0,
257 259 }
258 260 }
259 261
@@ -317,6 +319,8 @@ impl Item {
317 319 pwyw_min_cents: db_item.pwyw_min_cents,
318 320 publish_at: db_item.publish_at.map(|d| d.format(DATE_FMT_DATETIME_UTC).to_string()),
319 321 is_public: db_item.is_public,
322 + listed: db_item.listed,
323 + bundle_item_count: 0,
320 324 }
321 325 }
322 326 }
@@ -165,6 +165,9 @@ pub struct Item {
165 165 // Scheduled publish
166 166 pub publish_at: Option<String>,
167 167 pub is_public: bool,
168 + // Bundle
169 + pub listed: bool,
170 + pub bundle_item_count: i64,
168 171 }
169 172
170 173 impl Item {
@@ -454,7 +454,8 @@
454 454 background: var(--background);
455 455 }
456 456
457 - .upload-area:hover {
457 + .upload-area:hover,
458 + .upload-area.dragover {
458 459 border-color: var(--highlight);
459 460 }
460 461