Skip to main content

max / makenotwork

9.6 KB · 360 lines History Blame Raw
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 UPDATE` so re-adding updates the sort position.
12 #[tracing::instrument(skip_all)]
13 pub async fn add_item_to_bundle(
14 pool: &PgPool,
15 bundle_id: ItemId,
16 item_id: ItemId,
17 sort_order: i32,
18 ) -> Result<()> {
19 sqlx::query(
20 r#"
21 INSERT INTO bundle_items (bundle_id, item_id, sort_order)
22 VALUES ($1, $2, $3)
23 ON CONFLICT (bundle_id, item_id) DO UPDATE SET sort_order = $3
24 "#,
25 )
26 .bind(bundle_id)
27 .bind(item_id)
28 .bind(sort_order)
29 .execute(pool)
30 .await?;
31
32 Ok(())
33 }
34
35 /// Remove an item from a bundle.
36 #[tracing::instrument(skip_all)]
37 pub async fn remove_item_from_bundle(
38 pool: &PgPool,
39 bundle_id: ItemId,
40 item_id: ItemId,
41 ) -> Result<()> {
42 sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1 AND item_id = $2")
43 .bind(bundle_id)
44 .bind(item_id)
45 .execute(pool)
46 .await?;
47
48 Ok(())
49 }
50
51 /// Get all items included in a bundle, ordered by sort_order.
52 #[tracing::instrument(skip_all)]
53 pub async fn get_bundle_items(pool: &PgPool, bundle_id: ItemId) -> Result<Vec<DbItem>> {
54 let items = sqlx::query_as::<_, DbItem>(
55 r#"
56 SELECT i.* FROM items i
57 JOIN bundle_items bi ON i.id = bi.item_id
58 WHERE bi.bundle_id = $1 AND i.deleted_at IS NULL
59 ORDER BY bi.sort_order, bi.added_at
60 LIMIT 100
61 "#,
62 )
63 .bind(bundle_id)
64 .fetch_all(pool)
65 .await?;
66
67 Ok(items)
68 }
69
70 /// Get the IDs of all bundles that contain a given item.
71 #[tracing::instrument(skip_all)]
72 pub async fn get_bundles_containing_item(
73 pool: &PgPool,
74 item_id: ItemId,
75 ) -> Result<Vec<ItemId>> {
76 let ids: Vec<ItemId> = sqlx::query_scalar(
77 "SELECT bundle_id FROM bundle_items WHERE item_id = $1",
78 )
79 .bind(item_id)
80 .fetch_all(pool)
81 .await?;
82
83 Ok(ids)
84 }
85
86 /// Check whether a user has access to an item through any purchased bundle.
87 ///
88 /// Returns true if the user has a completed transaction for any bundle
89 /// that contains this item.
90 #[tracing::instrument(skip_all)]
91 pub async fn has_access_via_bundle(
92 pool: &PgPool,
93 user_id: UserId,
94 item_id: ItemId,
95 ) -> Result<bool> {
96 let exists: bool = sqlx::query_scalar(
97 r#"
98 SELECT EXISTS(
99 SELECT 1 FROM bundle_items bi
100 JOIN transactions t ON t.item_id = bi.bundle_id
101 WHERE bi.item_id = $1
102 AND t.buyer_id = $2
103 AND t.status = 'completed'
104 )
105 "#,
106 )
107 .bind(item_id)
108 .bind(user_id)
109 .fetch_one(pool)
110 .await?;
111
112 Ok(exists)
113 }
114
115 /// Get all non-bundle items in a project (candidates for inclusion in a bundle).
116 ///
117 /// Excludes the bundle itself (by `exclude_bundle_id`) and any items that are
118 /// already bundles (to prevent nesting).
119 #[tracing::instrument(skip_all)]
120 pub async fn get_bundleable_items(
121 pool: &PgPool,
122 project_id: ProjectId,
123 exclude_bundle_id: Option<ItemId>,
124 ) -> Result<Vec<DbItem>> {
125 let items = sqlx::query_as::<_, DbItem>(
126 r#"
127 SELECT * FROM items
128 WHERE project_id = $1
129 AND item_type != 'bundle'
130 AND deleted_at IS NULL
131 AND ($2::UUID IS NULL OR id != $2)
132 ORDER BY sort_order, created_at DESC
133 LIMIT 500
134 "#,
135 )
136 .bind(project_id)
137 .bind(exclude_bundle_id)
138 .fetch_all(pool)
139 .await?;
140
141 Ok(items)
142 }
143
144 /// Replace the full set of items in a bundle (transactional).
145 ///
146 /// Deletes all existing bundle_items rows for the bundle and inserts the new set.
147 /// `item_ids` is an ordered list; sort_order is derived from position.
148 /// Validates that both the bundle and all items belong to `owner_id`.
149 #[tracing::instrument(skip_all)]
150 pub async fn set_bundle_items(
151 pool: &PgPool,
152 bundle_id: ItemId,
153 item_ids: &[ItemId],
154 owner_id: UserId,
155 ) -> Result<()> {
156 let mut tx = pool.begin().await?;
157
158 // Verify bundle ownership
159 let owns_bundle: bool = sqlx::query_scalar(
160 "SELECT EXISTS(SELECT 1 FROM items i JOIN projects p ON p.id = i.project_id WHERE i.id = $1 AND p.user_id = $2)",
161 )
162 .bind(bundle_id)
163 .bind(owner_id)
164 .fetch_one(&mut *tx)
165 .await?;
166 if !owns_bundle {
167 return Err(crate::error::AppError::Forbidden);
168 }
169
170 // Verify all items belong to the same owner
171 if !item_ids.is_empty() {
172 let owned_count: i64 = sqlx::query_scalar(
173 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id WHERE i.id = ANY($1) AND p.user_id = $2",
174 )
175 .bind(item_ids)
176 .bind(owner_id)
177 .fetch_one(&mut *tx)
178 .await?;
179 if owned_count != item_ids.len() as i64 {
180 return Err(crate::error::AppError::BadRequest(
181 "All bundle items must belong to you".to_string(),
182 ));
183 }
184 }
185
186 sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1")
187 .bind(bundle_id)
188 .execute(&mut *tx)
189 .await?;
190
191 if !item_ids.is_empty() {
192 let bundle_ids: Vec<ItemId> = vec![bundle_id; item_ids.len()];
193 let orders: Vec<i32> = (0..item_ids.len() as i32).collect();
194 sqlx::query(
195 r#"
196 INSERT INTO bundle_items (bundle_id, item_id, sort_order)
197 SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::INT[])
198 ON CONFLICT (bundle_id, item_id) DO UPDATE SET sort_order = EXCLUDED.sort_order
199 "#,
200 )
201 .bind(&bundle_ids)
202 .bind(item_ids)
203 .bind(&orders)
204 .execute(&mut *tx)
205 .await?;
206 }
207
208 tx.commit().await?;
209 Ok(())
210 }
211
212 /// Count how many items are in a bundle.
213 #[tracing::instrument(skip_all)]
214 pub async fn get_bundle_item_count(pool: &PgPool, bundle_id: ItemId) -> Result<i64> {
215 let count: i64 = sqlx::query_scalar(
216 "SELECT COUNT(*) FROM bundle_items WHERE bundle_id = $1",
217 )
218 .bind(bundle_id)
219 .fetch_one(pool)
220 .await?;
221
222 Ok(count)
223 }
224
225 /// Get all bundle→child relationships for items within a project.
226 ///
227 /// Returns `(bundle_id, child_item_id)` pairs ordered by bundle then sort order.
228 #[tracing::instrument(skip_all)]
229 pub async fn get_project_bundle_map(
230 pool: &PgPool,
231 project_id: ProjectId,
232 ) -> Result<Vec<(ItemId, ItemId)>> {
233 let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
234 r#"
235 SELECT bi.bundle_id, bi.item_id
236 FROM bundle_items bi
237 JOIN items i ON i.id = bi.bundle_id
238 WHERE i.project_id = $1
239 ORDER BY bi.bundle_id, bi.sort_order
240 "#,
241 )
242 .bind(project_id)
243 .fetch_all(pool)
244 .await?;
245
246 Ok(rows)
247 }
248
249 /// Batch-load bundle maps for multiple projects at once.
250 ///
251 /// Returns (bundle_id, child_item_id) pairs for all bundles across the given projects.
252 #[tracing::instrument(skip_all)]
253 pub async fn get_bundle_maps_by_projects(
254 pool: &PgPool,
255 project_ids: &[super::ProjectId],
256 ) -> Result<Vec<(ItemId, ItemId)>> {
257 let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
258 r#"
259 SELECT bi.bundle_id, bi.item_id
260 FROM bundle_items bi
261 JOIN items i ON i.id = bi.bundle_id
262 WHERE i.project_id = ANY($1)
263 ORDER BY bi.bundle_id, bi.sort_order
264 "#,
265 )
266 .bind(project_ids)
267 .fetch_all(pool)
268 .await?;
269
270 Ok(rows)
271 }
272
273 /// Check if an item is a member of a bundle.
274 #[tracing::instrument(skip_all)]
275 pub async fn is_bundle_member(pool: &PgPool, bundle_id: ItemId, child_id: ItemId) -> Result<bool> {
276 let exists: bool = sqlx::query_scalar(
277 "SELECT EXISTS(SELECT 1 FROM bundle_items WHERE bundle_id = $1 AND item_id = $2)",
278 )
279 .bind(bundle_id)
280 .bind(child_id)
281 .fetch_one(pool)
282 .await?;
283
284 Ok(exists)
285 }
286
287 /// Set the `listed` flag on an item.
288 #[tracing::instrument(skip_all)]
289 pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> {
290 sqlx::query("UPDATE items SET listed = $2 WHERE id = $1")
291 .bind(item_id)
292 .bind(listed)
293 .execute(pool)
294 .await?;
295
296 Ok(())
297 }
298
299 #[cfg(test)]
300 mod tests {
301 use super::*;
302
303 #[test]
304 fn item_id_round_trip() {
305 let id = ItemId::new();
306 let s = id.to_string();
307 let parsed: ItemId = s.parse().unwrap();
308 assert_eq!(id, parsed);
309 }
310
311 #[test]
312 fn item_id_nil() {
313 let id = ItemId::nil();
314 assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
315 }
316
317 #[test]
318 fn project_id_constructible() {
319 let _id = ProjectId::new();
320 let _nil = ProjectId::nil();
321 }
322
323 #[test]
324 fn user_id_constructible() {
325 let _id = UserId::new();
326 let _nil = UserId::nil();
327 }
328
329 #[test]
330 fn item_id_uniqueness() {
331 let a = ItemId::new();
332 let b = ItemId::new();
333 assert_ne!(a, b);
334 }
335
336 #[test]
337 fn sort_order_vector_generation() {
338 // Mirrors the sort_order logic in set_bundle_items
339 let item_count = 5;
340 let orders: Vec<i32> = (0..item_count as i32).collect();
341 assert_eq!(orders, vec![0, 1, 2, 3, 4]);
342 }
343
344 #[test]
345 fn sort_order_empty() {
346 let orders: Vec<i32> = (0..0i32).collect();
347 assert!(orders.is_empty());
348 }
349
350 #[test]
351 fn bundle_id_replication_for_insert() {
352 // Mirrors the bundle_ids vector in set_bundle_items
353 let bundle_id = ItemId::nil();
354 let item_ids = vec![ItemId::new(); 3];
355 let bundle_ids: Vec<ItemId> = vec![bundle_id; item_ids.len()];
356 assert_eq!(bundle_ids.len(), 3);
357 assert!(bundle_ids.iter().all(|id| *id == bundle_id));
358 }
359 }
360