Skip to main content

max / makenotwork

10.6 KB · 377 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, fields(%bundle_id, %item_id, sort_order))]
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, fields(%bundle_id, %item_id))]
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, fields(%bundle_id))]
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, fields(%item_id))]
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, fields(%user_id, %item_id))]
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, fields(%project_id, ?exclude_bundle_id))]
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, fields(%bundle_id, %owner_id, item_count = item_ids.len()))]
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, fields(%bundle_id))]
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 /// Batch child-item counts for several bundles in one query, keyed by bundle.
226 /// Bundles with no children are absent from the map (callers default to 0).
227 #[tracing::instrument(skip_all, fields(bundle_count = bundle_ids.len()))]
228 pub async fn get_bundle_item_counts(
229 pool: &PgPool,
230 bundle_ids: &[ItemId],
231 ) -> Result<std::collections::HashMap<ItemId, i64>> {
232 let rows: Vec<(ItemId, i64)> = sqlx::query_as(
233 "SELECT bundle_id, COUNT(*) FROM bundle_items WHERE bundle_id = ANY($1) GROUP BY bundle_id",
234 )
235 .bind(bundle_ids)
236 .fetch_all(pool)
237 .await?;
238
239 Ok(rows.into_iter().collect())
240 }
241
242 /// Get all bundle→child relationships for items within a project.
243 ///
244 /// Returns `(bundle_id, child_item_id)` pairs ordered by bundle then sort order.
245 #[tracing::instrument(skip_all, fields(%project_id))]
246 pub async fn get_project_bundle_map(
247 pool: &PgPool,
248 project_id: ProjectId,
249 ) -> Result<Vec<(ItemId, ItemId)>> {
250 let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
251 r#"
252 SELECT bi.bundle_id, bi.item_id
253 FROM bundle_items bi
254 JOIN items i ON i.id = bi.bundle_id
255 WHERE i.project_id = $1
256 ORDER BY bi.bundle_id, bi.sort_order
257 "#,
258 )
259 .bind(project_id)
260 .fetch_all(pool)
261 .await?;
262
263 Ok(rows)
264 }
265
266 /// Batch-load bundle maps for multiple projects at once.
267 ///
268 /// Returns (bundle_id, child_item_id) pairs for all bundles across the given projects.
269 #[tracing::instrument(skip_all, fields(project_count = project_ids.len()))]
270 pub async fn get_bundle_maps_by_projects(
271 pool: &PgPool,
272 project_ids: &[super::ProjectId],
273 ) -> Result<Vec<(ItemId, ItemId)>> {
274 let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
275 r#"
276 SELECT bi.bundle_id, bi.item_id
277 FROM bundle_items bi
278 JOIN items i ON i.id = bi.bundle_id
279 WHERE i.project_id = ANY($1)
280 ORDER BY bi.bundle_id, bi.sort_order
281 "#,
282 )
283 .bind(project_ids)
284 .fetch_all(pool)
285 .await?;
286
287 Ok(rows)
288 }
289
290 /// Check if an item is a member of a bundle.
291 #[tracing::instrument(skip_all, fields(%bundle_id, %child_id))]
292 pub async fn is_bundle_member(pool: &PgPool, bundle_id: ItemId, child_id: ItemId) -> Result<bool> {
293 let exists: bool = sqlx::query_scalar(
294 "SELECT EXISTS(SELECT 1 FROM bundle_items WHERE bundle_id = $1 AND item_id = $2)",
295 )
296 .bind(bundle_id)
297 .bind(child_id)
298 .fetch_one(pool)
299 .await?;
300
301 Ok(exists)
302 }
303
304 /// Set the `listed` flag on an item.
305 #[tracing::instrument(skip_all, fields(%item_id, listed))]
306 pub async fn set_item_listed(pool: &PgPool, item_id: ItemId, listed: bool) -> Result<()> {
307 sqlx::query("UPDATE items SET listed = $2 WHERE id = $1")
308 .bind(item_id)
309 .bind(listed)
310 .execute(pool)
311 .await?;
312
313 Ok(())
314 }
315
316 #[cfg(test)]
317 mod tests {
318 use super::*;
319
320 #[test]
321 fn item_id_round_trip() {
322 let id = ItemId::new();
323 let s = id.to_string();
324 let parsed: ItemId = s.parse().unwrap();
325 assert_eq!(id, parsed);
326 }
327
328 #[test]
329 fn item_id_nil() {
330 let id = ItemId::nil();
331 assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
332 }
333
334 #[test]
335 fn project_id_constructible() {
336 let _id = ProjectId::new();
337 let _nil = ProjectId::nil();
338 }
339
340 #[test]
341 fn user_id_constructible() {
342 let _id = UserId::new();
343 let _nil = UserId::nil();
344 }
345
346 #[test]
347 fn item_id_uniqueness() {
348 let a = ItemId::new();
349 let b = ItemId::new();
350 assert_ne!(a, b);
351 }
352
353 #[test]
354 fn sort_order_vector_generation() {
355 // Mirrors the sort_order logic in set_bundle_items
356 let item_count = 5;
357 let orders: Vec<i32> = (0..item_count).collect();
358 assert_eq!(orders, vec![0, 1, 2, 3, 4]);
359 }
360
361 #[test]
362 fn sort_order_empty() {
363 let orders: Vec<i32> = (0..0i32).collect();
364 assert!(orders.is_empty());
365 }
366
367 #[test]
368 fn bundle_id_replication_for_insert() {
369 // Mirrors the bundle_ids vector in set_bundle_items
370 let bundle_id = ItemId::nil();
371 let item_ids = [ItemId::new(); 3];
372 let bundle_ids: Vec<ItemId> = vec![bundle_id; item_ids.len()];
373 assert_eq!(bundle_ids.len(), 3);
374 assert!(bundle_ids.iter().all(|id| *id == bundle_id));
375 }
376 }
377