Skip to main content

max / makenotwork

11.1 KB · 384 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 #[tracing::instrument(skip_all, fields(%bundle_id, %item_id))]
36 pub async fn remove_item_from_bundle(
37 pool: &PgPool,
38 bundle_id: ItemId,
39 item_id: ItemId,
40 ) -> Result<()> {
41 sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1 AND item_id = $2")
42 .bind(bundle_id)
43 .bind(item_id)
44 .execute(pool)
45 .await?;
46
47 Ok(())
48 }
49
50 /// Get all items included in a bundle, ordered by sort_order.
51 #[tracing::instrument(skip_all, fields(%bundle_id))]
52 pub async fn get_bundle_items(pool: &PgPool, bundle_id: ItemId) -> Result<Vec<DbItem>> {
53 let items = sqlx::query_as::<_, DbItem>(
54 r"
55 SELECT i.* FROM items i
56 JOIN bundle_items bi ON i.id = bi.item_id
57 WHERE bi.bundle_id = $1 AND i.deleted_at IS NULL
58 ORDER BY bi.sort_order, bi.added_at
59 LIMIT 100
60 ",
61 )
62 .bind(bundle_id)
63 .fetch_all(pool)
64 .await?;
65
66 Ok(items)
67 }
68
69 /// Get the IDs of all bundles that contain a given item.
70 #[tracing::instrument(skip_all, fields(%item_id))]
71 pub async fn get_bundles_containing_item(pool: &PgPool, item_id: ItemId) -> Result<Vec<ItemId>> {
72 let ids: Vec<ItemId> =
73 sqlx::query_scalar("SELECT bundle_id FROM bundle_items WHERE item_id = $1")
74 .bind(item_id)
75 .fetch_all(pool)
76 .await?;
77
78 Ok(ids)
79 }
80
81 /// Check whether a user has access to an item through any purchased bundle.
82 ///
83 /// Returns true if the user has a completed transaction for any bundle
84 /// that contains this item.
85 #[tracing::instrument(skip_all, fields(%user_id, %item_id))]
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 #[tracing::instrument(skip_all, fields(%project_id, ?exclude_bundle_id))]
115 pub async fn get_bundleable_items(
116 pool: &PgPool,
117 project_id: ProjectId,
118 exclude_bundle_id: Option<ItemId>,
119 ) -> Result<Vec<DbItem>> {
120 let items = sqlx::query_as::<_, DbItem>(
121 r"
122 SELECT * FROM items
123 WHERE project_id = $1
124 AND item_type != 'bundle'
125 AND deleted_at IS NULL
126 AND ($2::UUID IS NULL OR id != $2)
127 ORDER BY sort_order, created_at DESC
128 LIMIT 500
129 ",
130 )
131 .bind(project_id)
132 .bind(exclude_bundle_id)
133 .fetch_all(pool)
134 .await?;
135
136 Ok(items)
137 }
138
139 /// Replace the full set of items in a bundle (transactional).
140 ///
141 /// Deletes all existing bundle_items rows for the bundle and inserts the new set.
142 /// `item_ids` is an ordered list; sort_order is derived from position.
143 /// Validates that both the bundle and all items belong to `owner_id`.
144 #[tracing::instrument(skip_all, fields(%bundle_id, %owner_id, item_count = item_ids.len()))]
145 pub async fn set_bundle_items(
146 pool: &PgPool,
147 bundle_id: ItemId,
148 item_ids: &[ItemId],
149 owner_id: UserId,
150 ) -> Result<()> {
151 let mut tx = pool.begin().await?;
152
153 // Verify bundle ownership
154 let owns_bundle: bool = sqlx::query_scalar(
155 "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)",
156 )
157 .bind(bundle_id)
158 .bind(owner_id)
159 .fetch_one(&mut *tx)
160 .await?;
161 if !owns_bundle {
162 return Err(crate::error::AppError::Forbidden);
163 }
164
165 // Verify all items belong to the same owner
166 if !item_ids.is_empty() {
167 let owned_count: i64 = sqlx::query_scalar(
168 "SELECT COUNT(*) FROM items i JOIN projects p ON p.id = i.project_id WHERE i.id = ANY($1) AND p.user_id = $2",
169 )
170 .bind(item_ids)
171 .bind(owner_id)
172 .fetch_one(&mut *tx)
173 .await?;
174 if owned_count != item_ids.len() as i64 {
175 return Err(crate::error::AppError::BadRequest(
176 "All bundle items must belong to you".to_string(),
177 ));
178 }
179 }
180
181 sqlx::query("DELETE FROM bundle_items WHERE bundle_id = $1")
182 .bind(bundle_id)
183 .execute(&mut *tx)
184 .await?;
185
186 if !item_ids.is_empty() {
187 let bundle_ids: Vec<ItemId> = vec![bundle_id; item_ids.len()];
188 let orders: Vec<i32> = (0..item_ids.len() as i32).collect();
189 sqlx::query(
190 r"
191 INSERT INTO bundle_items (bundle_id, item_id, sort_order)
192 SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::INT[])
193 ON CONFLICT (bundle_id, item_id) DO UPDATE SET sort_order = EXCLUDED.sort_order
194 ",
195 )
196 .bind(&bundle_ids)
197 .bind(item_ids)
198 .bind(&orders)
199 .execute(&mut *tx)
200 .await?;
201 }
202
203 tx.commit().await?;
204 Ok(())
205 }
206
207 /// Count how many items are in a bundle.
208 #[tracing::instrument(skip_all, fields(%bundle_id))]
209 pub async fn get_bundle_item_count(pool: &PgPool, bundle_id: ItemId) -> Result<i64> {
210 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM bundle_items WHERE bundle_id = $1")
211 .bind(bundle_id)
212 .fetch_one(pool)
213 .await?;
214
215 Ok(count)
216 }
217
218 /// Batch child-item counts for several bundles in one query, keyed by bundle.
219 /// Bundles with no children are absent from the map (callers default to 0).
220 #[tracing::instrument(skip_all, fields(bundle_count = bundle_ids.len()))]
221 pub async fn get_bundle_item_counts(
222 pool: &PgPool,
223 bundle_ids: &[ItemId],
224 ) -> Result<std::collections::HashMap<ItemId, i64>> {
225 let rows: Vec<(ItemId, i64)> = sqlx::query_as(
226 "SELECT bundle_id, COUNT(*) FROM bundle_items WHERE bundle_id = ANY($1) GROUP BY bundle_id",
227 )
228 .bind(bundle_ids)
229 .fetch_all(pool)
230 .await?;
231
232 Ok(rows.into_iter().collect())
233 }
234
235 /// Get all bundle→child relationships for items within a project.
236 ///
237 /// Returns `(bundle_id, child_item_id)` pairs ordered by bundle then sort order.
238 #[tracing::instrument(skip_all, fields(%project_id))]
239 pub async fn get_project_bundle_map(
240 pool: &PgPool,
241 project_id: ProjectId,
242 ) -> Result<Vec<(ItemId, ItemId)>> {
243 let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
244 r"
245 SELECT bi.bundle_id, bi.item_id
246 FROM bundle_items bi
247 JOIN items i ON i.id = bi.bundle_id
248 WHERE i.project_id = $1
249 ORDER BY bi.bundle_id, bi.sort_order
250 ",
251 )
252 .bind(project_id)
253 .fetch_all(pool)
254 .await?;
255
256 Ok(rows)
257 }
258
259 /// Batch-load bundle maps for multiple projects at once.
260 ///
261 /// Returns (bundle_id, child_item_id) pairs for all bundles across the given projects.
262 #[tracing::instrument(skip_all, fields(project_count = project_ids.len()))]
263 pub async fn get_bundle_maps_by_projects(
264 pool: &PgPool,
265 project_ids: &[super::ProjectId],
266 ) -> Result<Vec<(ItemId, ItemId)>> {
267 let rows: Vec<(ItemId, ItemId)> = sqlx::query_as(
268 r"
269 SELECT bi.bundle_id, bi.item_id
270 FROM bundle_items bi
271 JOIN items i ON i.id = bi.bundle_id
272 WHERE i.project_id = ANY($1)
273 ORDER BY bi.bundle_id, bi.sort_order
274 ",
275 )
276 .bind(project_ids)
277 .fetch_all(pool)
278 .await?;
279
280 Ok(rows)
281 }
282
283 /// Check if an item is a member of a bundle.
284 #[tracing::instrument(skip_all, fields(%bundle_id, %child_id))]
285 pub async fn is_bundle_member(pool: &PgPool, bundle_id: ItemId, child_id: ItemId) -> Result<bool> {
286 let exists: bool = sqlx::query_scalar(
287 "SELECT EXISTS(SELECT 1 FROM bundle_items WHERE bundle_id = $1 AND item_id = $2)",
288 )
289 .bind(bundle_id)
290 .bind(child_id)
291 .fetch_one(pool)
292 .await?;
293
294 Ok(exists)
295 }
296
297 /// Set the `listed` flag on an item. UNSCOPED: takes no owner and updates by id
298 /// alone. Ownership is scoped IN the SQL (`project_id IN (SELECT id FROM projects
299 /// WHERE user_id = $3)`) so the toggle can't reach another user's item even if a
300 /// caller skips the upstream bundle/project ownership check (Sec-M2 defense in
301 /// depth, mirroring `media_files::delete`). Returns `true` if an owned item was
302 /// updated, `false` if none matched for `owner_id`.
303 #[tracing::instrument(skip_all, fields(%item_id, listed))]
304 pub async fn set_item_listed(
305 pool: &PgPool,
306 item_id: ItemId,
307 listed: bool,
308 owner_id: UserId,
309 ) -> Result<bool> {
310 let res = sqlx::query(
311 "UPDATE items SET listed = $2 \
312 WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)",
313 )
314 .bind(item_id)
315 .bind(listed)
316 .bind(owner_id)
317 .execute(pool)
318 .await?;
319
320 Ok(res.rows_affected() > 0)
321 }
322
323 #[cfg(test)]
324 mod tests {
325 use super::*;
326
327 #[test]
328 fn item_id_round_trip() {
329 let id = ItemId::new();
330 let s = id.to_string();
331 let parsed: ItemId = s.parse().unwrap();
332 assert_eq!(id, parsed);
333 }
334
335 #[test]
336 fn item_id_nil() {
337 let id = ItemId::nil();
338 assert_eq!(id.to_string(), "00000000-0000-0000-0000-000000000000");
339 }
340
341 #[test]
342 fn project_id_constructible() {
343 let _id = ProjectId::new();
344 let _nil = ProjectId::nil();
345 }
346
347 #[test]
348 fn user_id_constructible() {
349 let _id = UserId::new();
350 let _nil = UserId::nil();
351 }
352
353 #[test]
354 fn item_id_uniqueness() {
355 let a = ItemId::new();
356 let b = ItemId::new();
357 assert_ne!(a, b);
358 }
359
360 #[test]
361 fn sort_order_vector_generation() {
362 // Mirrors the sort_order logic in set_bundle_items
363 let item_count = 5;
364 let orders: Vec<i32> = (0..item_count).collect();
365 assert_eq!(orders, vec![0, 1, 2, 3, 4]);
366 }
367
368 #[test]
369 fn sort_order_empty() {
370 let orders: Vec<i32> = (0..0i32).collect();
371 assert!(orders.is_empty());
372 }
373
374 #[test]
375 fn bundle_id_replication_for_insert() {
376 // Mirrors the bundle_ids vector in set_bundle_items
377 let bundle_id = ItemId::nil();
378 let item_ids = [ItemId::new(); 3];
379 let bundle_ids: Vec<ItemId> = vec![bundle_id; item_ids.len()];
380 assert_eq!(bundle_ids.len(), 3);
381 assert!(bundle_ids.iter().all(|id| *id == bundle_id));
382 }
383 }
384