Skip to main content

max / makenotwork

9.0 KB · 312 lines History Blame Raw
1 //! Bulk and structural item operations: cross-project moves, batch
2 //! publish/unpublish/delete/repricing/tagging, and item duplication.
3
4 use sqlx::PgPool;
5
6 use crate::db::models::*;
7 use crate::db::{ItemId, PriceCents, ProjectId, UserId};
8 use crate::error::Result;
9
10 pub async fn move_item(
11 pool: &PgPool,
12 project_id: ProjectId,
13 user_id: UserId,
14 item_id: ItemId,
15 direction: &str,
16 ) -> Result<()> {
17 let mut tx = pool.begin().await?;
18
19 // Lock and fetch item IDs in display order (scoped to projects owned by user)
20 let item_ids: Vec<ItemId> = sqlx::query_scalar(
21 r#"
22 SELECT id FROM items
23 WHERE project_id = $1
24 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
25 ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE
26 "#,
27 )
28 .bind(project_id)
29 .bind(user_id)
30 .fetch_all(&mut *tx)
31 .await?;
32
33 let Some(pos) = item_ids.iter().position(|id| *id == item_id) else {
34 return Ok(());
35 };
36
37 let swap_pos = match direction {
38 "up" if pos > 0 => pos - 1,
39 "down" if pos + 1 < item_ids.len() => pos + 1,
40 _ => return Ok(()),
41 };
42
43 // Normalize all sort_orders, swapping the target pair (single batch UPDATE)
44 let mut ids = Vec::with_capacity(item_ids.len());
45 let mut orders = Vec::with_capacity(item_ids.len());
46 for (i, id) in item_ids.iter().enumerate() {
47 ids.push(*id);
48 orders.push(if i == pos {
49 swap_pos as i32
50 } else if i == swap_pos {
51 pos as i32
52 } else {
53 i as i32
54 });
55 }
56 sqlx::query(
57 "UPDATE items SET sort_order = batch.ord FROM UNNEST($1::UUID[], $2::INT[]) AS batch(id, ord) WHERE items.id = batch.id",
58 )
59 .bind(&ids)
60 .bind(&orders)
61 .execute(&mut *tx)
62 .await?;
63
64 tx.commit().await?;
65 Ok(())
66 }
67
68 /// Bulk-publish items: set `is_public = true` and clear any scheduled `publish_at`.
69 ///
70 /// Only affects items matching both the given IDs and project. Returns rows affected.
71 #[tracing::instrument(skip_all)]
72 pub async fn bulk_publish(
73 pool: &PgPool,
74 item_ids: &[ItemId],
75 project_id: ProjectId,
76 user_id: UserId,
77 ) -> Result<u64> {
78 let result = sqlx::query(
79 r#"
80 UPDATE items
81 SET is_public = true, publish_at = NULL, updated_at = NOW()
82 WHERE id = ANY($1) AND project_id = $2
83 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
84 AND removed_by_admin = false
85 "#,
86 )
87 .bind(item_ids)
88 .bind(project_id)
89 .bind(user_id)
90 .execute(pool)
91 .await?;
92
93 Ok(result.rows_affected())
94 }
95
96 /// Bulk-unpublish items: set `is_public = false`.
97 ///
98 /// Only affects items matching both the given IDs and project. Returns rows affected.
99 #[tracing::instrument(skip_all)]
100 pub async fn bulk_unpublish(
101 pool: &PgPool,
102 item_ids: &[ItemId],
103 project_id: ProjectId,
104 user_id: UserId,
105 ) -> Result<u64> {
106 let result = sqlx::query(
107 r#"
108 UPDATE items
109 SET is_public = false, updated_at = NOW()
110 WHERE id = ANY($1) AND project_id = $2
111 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
112 "#,
113 )
114 .bind(item_ids)
115 .bind(project_id)
116 .bind(user_id)
117 .execute(pool)
118 .await?;
119
120 Ok(result.rows_affected())
121 }
122
123 /// Soft-delete items from a project (sets deleted_at, recoverable for 7 days).
124 ///
125 /// Only affects items matching both the given IDs and project. Returns rows affected.
126 #[tracing::instrument(skip_all)]
127 pub async fn bulk_delete(
128 pool: &PgPool,
129 item_ids: &[ItemId],
130 project_id: ProjectId,
131 user_id: UserId,
132 ) -> Result<u64> {
133 let result = sqlx::query(
134 r#"
135 UPDATE items SET deleted_at = NOW(), is_public = false
136 WHERE id = ANY($1) AND project_id = $2 AND deleted_at IS NULL
137 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
138 "#,
139 )
140 .bind(item_ids)
141 .bind(project_id)
142 .bind(user_id)
143 .execute(pool)
144 .await?;
145
146 Ok(result.rows_affected())
147 }
148
149 /// Bulk-update price on selected items.
150 ///
151 /// Only affects items matching both the given IDs and project. Returns rows affected.
152 #[tracing::instrument(skip_all)]
153 pub async fn bulk_update_price(
154 pool: &PgPool,
155 item_ids: &[ItemId],
156 project_id: ProjectId,
157 user_id: UserId,
158 price_cents: PriceCents,
159 ) -> Result<u64> {
160 let result = sqlx::query(
161 r#"
162 UPDATE items SET price_cents = $4
163 WHERE id = ANY($1) AND project_id = $2
164 AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
165 "#,
166 )
167 .bind(item_ids)
168 .bind(project_id)
169 .bind(user_id)
170 .bind(price_cents)
171 .execute(pool)
172 .await?;
173
174 Ok(result.rows_affected())
175 }
176
177 /// Bulk-add a tag to selected items (skips duplicates via ON CONFLICT).
178 ///
179 /// Returns number of new tag associations created.
180 #[tracing::instrument(skip_all)]
181 pub async fn bulk_add_tag(
182 pool: &PgPool,
183 item_ids: &[ItemId],
184 project_id: ProjectId,
185 user_id: UserId,
186 tag_id: crate::db::TagId,
187 ) -> Result<u64> {
188 // Verify all items belong to the project owned by this user,
189 // then insert tag associations for each.
190 let result = sqlx::query(
191 r#"
192 INSERT INTO item_tags (item_id, tag_id)
193 SELECT i.id, $4
194 FROM items i
195 JOIN projects p ON i.project_id = p.id
196 WHERE i.id = ANY($1) AND i.project_id = $2 AND p.user_id = $3
197 ON CONFLICT (item_id, tag_id) DO NOTHING
198 "#,
199 )
200 .bind(item_ids)
201 .bind(project_id)
202 .bind(user_id)
203 .bind(tag_id)
204 .execute(pool)
205 .await?;
206
207 Ok(result.rows_affected())
208 }
209
210 /// Duplicate an item and its metadata (tags, chapters, content insertion placements).
211 ///
212 /// Creates a draft copy with "Copy of ..." title. Does not copy versions (S3 files),
213 /// license keys, download codes, or discount codes.
214 #[tracing::instrument(skip_all)]
215 pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> {
216 let mut tx = pool.begin().await?;
217
218 // Generate a unique slug for the copy (verify ownership via project)
219 let source = sqlx::query_as::<_, DbItem>(
220 "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
221 )
222 .bind(source_id)
223 .bind(user_id)
224 .fetch_one(&mut *tx)
225 .await?;
226 let copy_title = format!("Copy of {}", &source.title);
227 let copy_title: String = copy_title.chars().take(200).collect();
228 let mut slug = crate::helpers::slugify(&copy_title);
229 if super::item_slug_exists(&mut *tx, source.project_id, &slug).await? {
230 let base = slug.clone();
231 let mut counter = 2u32;
232 loop {
233 if counter > 100 {
234 return Err(crate::error::AppError::BadRequest(
235 "Too many copies with similar names. Rename an existing copy first.".to_string(),
236 ));
237 }
238 slug = crate::db::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
239 if !super::item_slug_exists(&mut *tx, source.project_id, &slug).await? {
240 break;
241 }
242 counter += 1;
243 }
244 }
245
246 // Step 1: Clone item row
247 let new_item = sqlx::query_as::<_, DbItem>(
248 r#"
249 INSERT INTO items (
250 project_id, title, description, price_cents, item_type, thumbnail_url,
251 sort_order, body, word_count, reading_time_minutes, duration_seconds,
252 episode_number, enable_license_keys, default_max_activations,
253 pwyw_enabled, pwyw_min_cents, is_public, slug
254 )
255 SELECT
256 project_id, LEFT('Copy of ' || title, 200), description, price_cents,
257 item_type, thumbnail_url, sort_order, body, word_count,
258 reading_time_minutes, duration_seconds, episode_number,
259 enable_license_keys, default_max_activations, pwyw_enabled,
260 pwyw_min_cents, false, $2
261 FROM items WHERE id = $1
262 RETURNING *
263 "#,
264 )
265 .bind(source_id)
266 .bind(&slug)
267 .fetch_one(&mut *tx)
268 .await?;
269
270 // Step 2: Copy tags
271 sqlx::query(
272 r#"
273 INSERT INTO item_tags (item_id, tag_id, is_primary)
274 SELECT $2, tag_id, is_primary FROM item_tags WHERE item_id = $1
275 "#,
276 )
277 .bind(source_id)
278 .bind(new_item.id)
279 .execute(&mut *tx)
280 .await?;
281
282 // Step 3: Copy chapters
283 sqlx::query(
284 r#"
285 INSERT INTO chapters (item_id, title, start_seconds, sort_order)
286 SELECT $2, title, start_seconds, sort_order FROM chapters WHERE item_id = $1
287 "#,
288 )
289 .bind(source_id)
290 .bind(new_item.id)
291 .execute(&mut *tx)
292 .await?;
293
294 // Step 4: Copy content insertion placements
295 sqlx::query(
296 r#"
297 INSERT INTO content_insertion_placements (item_id, insertion_id, position, offset_ms, sort_order)
298 SELECT $2, insertion_id, position, offset_ms, sort_order
299 FROM content_insertion_placements WHERE item_id = $1
300 "#,
301 )
302 .bind(source_id)
303 .bind(new_item.id)
304 .execute(&mut *tx)
305 .await?;
306
307 tx.commit().await?;
308
309 Ok(new_item)
310 }
311
312