Skip to main content

max / makenotwork

7.4 KB · 215 lines History Blame Raw
1 //! Ordered image galleries for items and projects (launchplan S.1).
2 //!
3 //! Two near-identical tables (`item_images` / `project_images`) keyed by parent,
4 //! each row an image with an alt string and an explicit `position`. The single
5 //! `cover_image_url` on items/projects is unaffected — it stays the OG/card
6 //! image; these rows are the additive carousel gallery.
7 //!
8 //! `s3_key` + `file_size_bytes` are stored so a delete decrements storage from
9 //! the recorded size (no S3 HEAD) and the cleanup garbage-collector recognizes
10 //! live gallery objects via the S3_KEY_REFS registry.
11
12 use sqlx::{PgExecutor, PgPool};
13 use uuid::Uuid;
14
15 use super::{ItemId, ProjectId};
16 use crate::error::Result;
17
18 /// One gallery image row (shared shape for item and project galleries).
19 #[derive(Debug, Clone, sqlx::FromRow)]
20 pub struct GalleryImage {
21 pub id: Uuid,
22 pub s3_key: String,
23 pub image_url: String,
24 pub alt: String,
25 pub position: i32,
26 pub file_size_bytes: i64,
27 }
28
29 /// Maximum gallery images per entity. Keeps a single creator from ballooning
30 /// storage with one listing and bounds the carousel length.
31 pub const MAX_GALLERY_IMAGES: i64 = 8;
32
33 // ---------------------------------------------------------------------------
34 // Item galleries
35 // ---------------------------------------------------------------------------
36
37 /// List an item's gallery images in display order.
38 #[tracing::instrument(skip_all)]
39 pub async fn list_for_item<'e>(executor: impl PgExecutor<'e>, item_id: ItemId) -> Result<Vec<GalleryImage>> {
40 let rows = sqlx::query_as::<_, GalleryImage>(
41 "SELECT id, s3_key, image_url, alt, position, file_size_bytes \
42 FROM item_images WHERE item_id = $1 ORDER BY position, created_at",
43 )
44 .bind(item_id)
45 .fetch_all(executor)
46 .await?;
47 Ok(rows)
48 }
49
50 /// Count an item's gallery images (for the per-entity cap check).
51 #[tracing::instrument(skip_all)]
52 pub async fn count_for_item<'e>(executor: impl PgExecutor<'e>, item_id: ItemId) -> Result<i64> {
53 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1")
54 .bind(item_id)
55 .fetch_one(executor)
56 .await?;
57 Ok(count)
58 }
59
60 /// Insert a gallery image at the end (position = current max + 1). Returns the
61 /// new row id. Takes an executor so it can run inside the confirm transaction.
62 #[tracing::instrument(skip_all)]
63 pub async fn insert_for_item<'e>(
64 executor: impl PgExecutor<'e>,
65 item_id: ItemId,
66 s3_key: &str,
67 image_url: &str,
68 alt: &str,
69 file_size_bytes: i64,
70 ) -> Result<Uuid> {
71 let id: Uuid = sqlx::query_scalar(
72 "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes) \
73 VALUES ($1, $2, $3, $4, \
74 COALESCE((SELECT MAX(position) + 1 FROM item_images WHERE item_id = $1), 0), \
75 $5) \
76 RETURNING id",
77 )
78 .bind(item_id)
79 .bind(s3_key)
80 .bind(image_url)
81 .bind(alt)
82 .bind(file_size_bytes)
83 .fetch_one(executor)
84 .await?;
85 Ok(id)
86 }
87
88 /// Delete one item gallery image IF it belongs to an item owned by `user_id`.
89 /// Returns the deleted row (for storage decrement + S3 cleanup), or None if it
90 /// did not exist or the caller does not own it.
91 #[tracing::instrument(skip_all)]
92 pub async fn delete_for_item<'e>(
93 executor: impl PgExecutor<'e>,
94 image_id: Uuid,
95 user_id: super::UserId,
96 ) -> Result<Option<GalleryImage>> {
97 let row = sqlx::query_as::<_, GalleryImage>(
98 "DELETE FROM item_images WHERE id = $1 AND item_id IN ( \
99 SELECT i.id FROM items i JOIN projects p ON p.id = i.project_id WHERE p.user_id = $2 \
100 ) RETURNING id, s3_key, image_url, alt, position, file_size_bytes",
101 )
102 .bind(image_id)
103 .bind(user_id)
104 .fetch_optional(executor)
105 .await?;
106 Ok(row)
107 }
108
109 /// Reorder an item's gallery to match `ordered_ids` (ids not belonging to the
110 /// item are ignored). Positions are assigned by list order.
111 #[tracing::instrument(skip_all)]
112 pub async fn reorder_item(pool: &PgPool, item_id: ItemId, ordered_ids: &[Uuid]) -> Result<()> {
113 let mut tx = pool.begin().await?;
114 for (pos, id) in ordered_ids.iter().enumerate() {
115 sqlx::query("UPDATE item_images SET position = $1 WHERE id = $2 AND item_id = $3")
116 .bind(pos as i32)
117 .bind(id)
118 .bind(item_id)
119 .execute(&mut *tx)
120 .await?;
121 }
122 tx.commit().await?;
123 Ok(())
124 }
125
126 // ---------------------------------------------------------------------------
127 // Project galleries
128 // ---------------------------------------------------------------------------
129
130 /// List a project's gallery images in display order.
131 #[tracing::instrument(skip_all)]
132 pub async fn list_for_project<'e>(executor: impl PgExecutor<'e>, project_id: ProjectId) -> Result<Vec<GalleryImage>> {
133 let rows = sqlx::query_as::<_, GalleryImage>(
134 "SELECT id, s3_key, image_url, alt, position, file_size_bytes \
135 FROM project_images WHERE project_id = $1 ORDER BY position, created_at",
136 )
137 .bind(project_id)
138 .fetch_all(executor)
139 .await?;
140 Ok(rows)
141 }
142
143 /// Count a project's gallery images (for the per-entity cap check).
144 #[tracing::instrument(skip_all)]
145 pub async fn count_for_project<'e>(executor: impl PgExecutor<'e>, project_id: ProjectId) -> Result<i64> {
146 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM project_images WHERE project_id = $1")
147 .bind(project_id)
148 .fetch_one(executor)
149 .await?;
150 Ok(count)
151 }
152
153 /// Insert a project gallery image at the end. Returns the new row id.
154 #[tracing::instrument(skip_all)]
155 pub async fn insert_for_project<'e>(
156 executor: impl PgExecutor<'e>,
157 project_id: ProjectId,
158 s3_key: &str,
159 image_url: &str,
160 alt: &str,
161 file_size_bytes: i64,
162 ) -> Result<Uuid> {
163 let id: Uuid = sqlx::query_scalar(
164 "INSERT INTO project_images (project_id, s3_key, image_url, alt, position, file_size_bytes) \
165 VALUES ($1, $2, $3, $4, \
166 COALESCE((SELECT MAX(position) + 1 FROM project_images WHERE project_id = $1), 0), \
167 $5) \
168 RETURNING id",
169 )
170 .bind(project_id)
171 .bind(s3_key)
172 .bind(image_url)
173 .bind(alt)
174 .bind(file_size_bytes)
175 .fetch_one(executor)
176 .await?;
177 Ok(id)
178 }
179
180 /// Delete one project gallery image IF the project is owned by `user_id`.
181 /// Returns the deleted row, or None if missing / not owned.
182 #[tracing::instrument(skip_all)]
183 pub async fn delete_for_project<'e>(
184 executor: impl PgExecutor<'e>,
185 image_id: Uuid,
186 user_id: super::UserId,
187 ) -> Result<Option<GalleryImage>> {
188 let row = sqlx::query_as::<_, GalleryImage>(
189 "DELETE FROM project_images WHERE id = $1 AND project_id IN ( \
190 SELECT id FROM projects WHERE user_id = $2 \
191 ) RETURNING id, s3_key, image_url, alt, position, file_size_bytes",
192 )
193 .bind(image_id)
194 .bind(user_id)
195 .fetch_optional(executor)
196 .await?;
197 Ok(row)
198 }
199
200 /// Reorder a project's gallery to match `ordered_ids`.
201 #[tracing::instrument(skip_all)]
202 pub async fn reorder_project(pool: &PgPool, project_id: ProjectId, ordered_ids: &[Uuid]) -> Result<()> {
203 let mut tx = pool.begin().await?;
204 for (pos, id) in ordered_ids.iter().enumerate() {
205 sqlx::query("UPDATE project_images SET position = $1 WHERE id = $2 AND project_id = $3")
206 .bind(pos as i32)
207 .bind(id)
208 .bind(project_id)
209 .execute(&mut *tx)
210 .await?;
211 }
212 tx.commit().await?;
213 Ok(())
214 }
215