Skip to main content

sl_map_web/routes/
renders.rs

1//! HTTP handlers for the persisted saved-renders library.
2//!
3//! These endpoints serve the on-disk image files and metadata for
4//! `saved_renders` rows. They are separate from `/api/render/{id}/...`
5//! (which serves the in-memory job state for live progress) so that
6//! finished renders remain accessible after the in-memory job has been
7//! evicted.
8
9use std::sync::atomic::Ordering;
10
11use axum::Json;
12use axum::extract::{Path, Query, State};
13use axum::http::StatusCode as ReqwestStatusCode;
14use axum::http::header;
15use axum::response::{IntoResponse as _, Response};
16use chrono::DateTime;
17use chrono::Utc;
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::auth::{CurrentUser, uuid_from_bytes};
22use crate::error::Error;
23use crate::jobs::Metadata;
24use crate::library::{self, Destination, RenderView};
25use crate::routes::render::SavedRenderSettings;
26use crate::state::AppState;
27use crate::storage;
28
29/// Query parameters for `GET /api/renders`.
30#[derive(Debug, Deserialize)]
31pub struct ListQuery {
32    /// `"personal"` or `"group:<uuid>"`.
33    pub scope: String,
34    /// Optional status filter: `in_progress`, `done`, or `failed`.
35    pub status: Option<String>,
36}
37
38/// Response carrying a list of saved renders.
39#[derive(Debug, Serialize)]
40pub struct ListRendersResponse {
41    /// the renders, newest first.
42    pub renders: Vec<RenderView>,
43}
44
45/// Response carrying a single saved render's metadata.
46#[derive(Debug, Serialize)]
47pub struct RenderResponse {
48    /// the requested render's metadata (status, owner, settings,
49    /// timestamps) — the image bytes are fetched separately via the
50    /// `/image` and `/image-without-route` sub-routes.
51    pub render: RenderView,
52}
53
54/// `GET /api/renders?scope=...&status=...` — list renders in a scope.
55///
56/// Group members (non-owners) only see `status='done'` rows.
57///
58/// # Errors
59///
60/// Returns [`Error::BadRequest`] for an invalid scope; [`Error::Forbidden`]
61/// if the caller is not allowed to view that scope.
62pub async fn list(
63    user: CurrentUser,
64    State(state): State<AppState>,
65    Query(query): Query<ListQuery>,
66) -> Result<Json<ListRendersResponse>, Error> {
67    let destination = Destination::parse(&query.scope)?;
68    library::assert_can_view(&state.db, user.user_id, destination).await?;
69    // The member-only-finished restriction is folded into the group SQL
70    // (`gm.role = 'owner' OR r.status = 'done'`); a member supplying
71    // `status=in_progress` simply gets an empty result.
72    let status = query
73        .status
74        .as_ref()
75        .map(|s| s.trim().to_owned())
76        .filter(|s| !s.is_empty());
77    let renders = fetch_renders_for(&state, user.user_id, destination, status.as_deref()).await?;
78    Ok(Json(ListRendersResponse { renders }))
79}
80
81/// `GET /api/renders/{id}` — fetch a single render's metadata.
82///
83/// # Errors
84///
85/// Returns [`Error::Forbidden`] / [`Error::NotFound`] as appropriate.
86pub async fn get(
87    user: CurrentUser,
88    State(state): State<AppState>,
89    Path(render_id): Path<Uuid>,
90) -> Result<Json<RenderResponse>, Error> {
91    let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
92    let destination =
93        library::destination_from_columns(row.owner_user_id.clone(), row.owner_group_id.clone())?;
94    let creator_names = match row.created_by {
95        Some(id) => lookup_user_names(&state, id).await.ok(),
96        None => None,
97    };
98    let (creator_username, creator_legacy) = match creator_names {
99        Some((u, l)) => (Some(u), Some(l)),
100        None => (None, None),
101    };
102    let notecard_name = match row.notecard_id {
103        Some(id) => lookup_notecard_name(&state, id).await?,
104        None => None,
105    };
106    let glw_data_name = match row.glw_data_id {
107        Some(id) => lookup_glw_data_name(&state, id).await?,
108        None => None,
109    };
110    let view = RenderView {
111        render_id,
112        destination,
113        created_by: row.created_by,
114        created_by_username: creator_username,
115        created_by_legacy_name: creator_legacy,
116        notecard_id: row.notecard_id,
117        notecard_name,
118        kind: row.kind,
119        status: row.status,
120        error_message: row.error_message,
121        created_at: row.created_at,
122        finished_at: row.finished_at,
123        has_without_route: row.image_without_route_filename.is_some(),
124        content_type: row.content_type,
125        lower_left_x: row.lower_left_x,
126        lower_left_y: row.lower_left_y,
127        upper_right_x: row.upper_right_x,
128        upper_right_y: row.upper_right_y,
129        glw_data_id: row.glw_data_id,
130        glw_data_name,
131    };
132    Ok(Json(RenderResponse { render: view }))
133}
134
135/// `DELETE /api/renders/{id}` — delete a saved render and its image files.
136/// Personal owner or group owner only.
137///
138/// # Errors
139///
140/// Returns [`Error::Forbidden`] for permission failures.
141pub async fn delete(
142    user: CurrentUser,
143    State(state): State<AppState>,
144    Path(render_id): Path<Uuid>,
145) -> Result<Response, Error> {
146    let row = library::assert_can_delete_render(&state.db, user.user_id, render_id).await?;
147    sqlx::query("DELETE FROM saved_renders WHERE render_id = ?1")
148        .bind(render_id.as_bytes().to_vec())
149        .execute(&state.db)
150        .await
151        .map_err(|err| {
152            tracing::error!("render delete failed: {err}");
153            Error::Database
154        })?;
155    // Best-effort unlink of the image files; raise the sweeper flag if it
156    // fails so the orphan sweeper retries.
157    for filename in [row.image_filename, row.image_without_route_filename]
158        .into_iter()
159        .flatten()
160    {
161        if let Err(err) = storage::try_delete_render_file(&state.config.storage_dir, &filename) {
162            tracing::warn!("inline unlink of {filename} failed: {err}");
163            state.library_cleanup_dirty.store(true, Ordering::Release);
164        }
165    }
166    Ok((ReqwestStatusCode::NO_CONTENT, "").into_response())
167}
168
169/// `GET /api/renders/{id}/image` — serve the persisted primary image.
170///
171/// # Errors
172///
173/// As for [`get`]; also [`Error::BadRequest`] if the render is not in `done`
174/// state.
175pub async fn image(
176    user: CurrentUser,
177    State(state): State<AppState>,
178    Path(render_id): Path<Uuid>,
179) -> Result<Response, Error> {
180    serve_image(user, state, render_id, false, false).await
181}
182
183/// `GET /api/renders/{id}/image-without-route` — serve the without-route
184/// variant if one was saved.
185///
186/// # Errors
187///
188/// As [`image()`].
189pub async fn image_without_route(
190    user: CurrentUser,
191    State(state): State<AppState>,
192    Path(render_id): Path<Uuid>,
193) -> Result<Response, Error> {
194    serve_image(user, state, render_id, true, false).await
195}
196
197/// `GET /api/renders/{id}/download` — like `/image` but with
198/// `Content-Disposition: attachment` so the browser saves rather than
199/// inline-renders.
200///
201/// # Errors
202///
203/// As [`image()`].
204pub async fn download(
205    user: CurrentUser,
206    State(state): State<AppState>,
207    Path(render_id): Path<Uuid>,
208) -> Result<Response, Error> {
209    serve_image(user, state, render_id, false, true).await
210}
211
212/// `GET /api/renders/{id}/download-without-route` — like
213/// `/image-without-route` but with `Content-Disposition: attachment`.
214///
215/// # Errors
216///
217/// As [`image()`].
218pub async fn download_without_route(
219    user: CurrentUser,
220    State(state): State<AppState>,
221    Path(render_id): Path<Uuid>,
222) -> Result<Response, Error> {
223    serve_image(user, state, render_id, true, true).await
224}
225
226/// `GET /api/renders/{id}/metadata` — serve the metadata JSON.
227///
228/// The stored bytes are round-tripped through [`Metadata`] rather than
229/// being replayed verbatim, so an unexpected writer to the column cannot
230/// cause the API to serve arbitrary trusted-content-type JSON.
231///
232/// # Errors
233///
234/// As [`get`].
235pub async fn metadata(
236    user: CurrentUser,
237    State(state): State<AppState>,
238    Path(render_id): Path<Uuid>,
239) -> Result<Response, Error> {
240    let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
241    let raw = row
242        .metadata_json
243        .ok_or_else(|| Error::NotFound(format!("render {render_id} has no metadata yet")))?;
244    let parsed: Metadata = serde_json::from_str(&raw)?;
245    Ok(Json(parsed).into_response())
246}
247
248/// `GET /api/renders/{id}/settings` — serve the settings JSON used to launch
249/// the render. Used by the UI's "Regenerate" button.
250///
251/// The stored bytes are round-tripped through [`SavedRenderSettings`]
252/// rather than being replayed verbatim; same rationale as [`metadata`].
253///
254/// # Errors
255///
256/// As [`get`].
257pub async fn settings(
258    user: CurrentUser,
259    State(state): State<AppState>,
260    Path(render_id): Path<Uuid>,
261) -> Result<Response, Error> {
262    let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
263    let parsed: SavedRenderSettings = serde_json::from_str(&row.settings_json)?;
264    Ok(Json(parsed).into_response())
265}
266
267/// Shared body for `image`, `image_without_route`, `download`.
268async fn serve_image(
269    user: CurrentUser,
270    state: AppState,
271    render_id: Uuid,
272    want_without_route: bool,
273    attachment: bool,
274) -> Result<Response, Error> {
275    let row = library::assert_can_read_render(&state.db, user.user_id, render_id).await?;
276    if row.status != "done" {
277        return Err(Error::BadRequest(format!(
278            "render is not finished (status={})",
279            row.status
280        )));
281    }
282    // Compute the download stem before we move fields out of `row` for
283    // the image lookup below.
284    let download_stem = if attachment {
285        Some(render_download_stem(&state, render_id, &row).await)
286    } else {
287        None
288    };
289    let filename = if want_without_route {
290        row.image_without_route_filename.ok_or_else(|| {
291            Error::NotFound(format!("render {render_id} has no without-route variant"))
292        })?
293    } else {
294        row.image_filename
295            .ok_or_else(|| Error::NotFound(format!("render {render_id} has no image")))?
296    };
297    let content_type = row
298        .content_type
299        .unwrap_or_else(|| "application/octet-stream".to_owned());
300    let bytes = storage::read_render_file(&state.config.storage_dir, &filename).await?;
301    let mut headers = axum::http::HeaderMap::new();
302    if let Ok(v) = axum::http::HeaderValue::from_str(&content_type) {
303        drop(headers.insert(header::CONTENT_TYPE, v));
304    }
305    if let Some(stem) = download_stem {
306        let ext = storage::ext_for_content_type(&content_type);
307        let suffix = if want_without_route { "-no-route" } else { "" };
308        let disposition = format!("attachment; filename=\"{stem}{suffix}.{ext}\"");
309        if let Ok(v) = axum::http::HeaderValue::from_str(&disposition) {
310            drop(headers.insert(header::CONTENT_DISPOSITION, v));
311        }
312    }
313    Ok((headers, bytes).into_response())
314}
315
316/// Compose a download filename stem for a saved render. Prefers, in
317/// order: the linked notecard's display name (sanitised), the grid
318/// rectangle as `grid-{ll_x}-{ll_y}-{ur_x}-{ur_y}` when the bounds are
319/// known, and `render-{render_id}` as a last-resort fallback. The stem
320/// has no extension; the caller appends the right one for the content
321/// type.
322async fn render_download_stem(
323    state: &AppState,
324    render_id: Uuid,
325    row: &library::RenderRow,
326) -> String {
327    if let Some(id) = row.notecard_id
328        && let Ok(Some(name)) = lookup_notecard_name(state, id).await
329        && let Some(stem) = crate::routes::notecards::sanitise_for_filename(&name)
330    {
331        return stem;
332    }
333    if let (Some(ll_x), Some(ll_y), Some(ur_x), Some(ur_y)) = (
334        row.lower_left_x,
335        row.lower_left_y,
336        row.upper_right_x,
337        row.upper_right_y,
338    ) {
339        return format!("grid-{ll_x}-{ll_y}-{ur_x}-{ur_y}");
340    }
341    format!("render-{render_id}")
342}
343
344/// Run the list query for the given scope. The `status` filter, if Some, is
345/// applied with an `=` comparison.
346async fn fetch_renders_for(
347    state: &AppState,
348    current_user: Uuid,
349    destination: Destination,
350    status: Option<&str>,
351) -> Result<Vec<RenderView>, Error> {
352    let rows: Vec<RenderListRow> = match (destination, status) {
353        (Destination::Personal, None) => {
354            sqlx::query_as(LIST_PERSONAL_SQL)
355                .bind(current_user.as_bytes().to_vec())
356                .fetch_all(&state.db)
357                .await
358        }
359        (Destination::Personal, Some(s)) => {
360            sqlx::query_as(LIST_PERSONAL_STATUS_SQL)
361                .bind(current_user.as_bytes().to_vec())
362                .bind(s)
363                .fetch_all(&state.db)
364                .await
365        }
366        (Destination::Group { group_id }, None) => {
367            sqlx::query_as(LIST_GROUP_SQL)
368                .bind(group_id.as_bytes().to_vec())
369                .bind(current_user.as_bytes().to_vec())
370                .fetch_all(&state.db)
371                .await
372        }
373        (Destination::Group { group_id }, Some(s)) => {
374            sqlx::query_as(LIST_GROUP_STATUS_SQL)
375                .bind(group_id.as_bytes().to_vec())
376                .bind(current_user.as_bytes().to_vec())
377                .bind(s)
378                .fetch_all(&state.db)
379                .await
380        }
381    }
382    .map_err(|err| {
383        tracing::error!("list renders failed: {err}");
384        Error::Database
385    })?;
386    let mut out = Vec::with_capacity(rows.len());
387    for row in rows {
388        out.push(row.into_view()?);
389    }
390    Ok(out)
391}
392
393/// Row shape returned by the listing queries. Uses a `FromRow` struct
394/// instead of a tuple because the column list is past sqlx's tuple-
395/// `FromRow` arity (16).
396#[derive(sqlx::FromRow)]
397struct RenderListRow {
398    /// raw bytes of `saved_renders.render_id`.
399    render_id: Vec<u8>,
400    /// raw bytes of `saved_renders.owner_user_id`, if set.
401    owner_user_id: Option<Vec<u8>>,
402    /// raw bytes of `saved_renders.owner_group_id`, if set.
403    owner_group_id: Option<Vec<u8>>,
404    /// raw bytes of the user that created the render. `None` when the
405    /// account has been deleted (FK is `ON DELETE SET NULL`).
406    created_by: Option<Vec<u8>>,
407    /// the creating user's `username`, if the account still exists.
408    creator_username: Option<String>,
409    /// the creating user's `legacy_name`, if the account still exists.
410    creator_legacy_name: Option<String>,
411    /// raw bytes of the linked notecard id, if any.
412    notecard_id: Option<Vec<u8>>,
413    /// the linked notecard's display name, if any.
414    notecard_name: Option<String>,
415    /// render kind (`grid_rectangle` or `usb_notecard`).
416    kind: String,
417    /// render status (`in_progress`, `done`, `failed`).
418    status: String,
419    /// error message if `status = 'failed'`.
420    error_message: Option<String>,
421    /// filename of the without-route image, if one was saved.
422    image_without_route_filename: Option<String>,
423    /// MIME type of the stored image.
424    content_type: Option<String>,
425    /// row creation timestamp.
426    created_at: DateTime<Utc>,
427    /// terminal-state timestamp, if any.
428    finished_at: Option<DateTime<Utc>>,
429    /// lower-left x grid coordinate of the rendered rectangle, if known.
430    lower_left_x: Option<i64>,
431    /// lower-left y grid coordinate of the rendered rectangle, if known.
432    lower_left_y: Option<i64>,
433    /// upper-right x grid coordinate of the rendered rectangle, if known.
434    upper_right_x: Option<i64>,
435    /// upper-right y grid coordinate of the rendered rectangle, if known.
436    upper_right_y: Option<i64>,
437    /// raw bytes of the linked saved_glw_data row, if any.
438    glw_data_id: Option<Vec<u8>>,
439    /// display name of the linked saved_glw_data row, if any.
440    glw_data_name: Option<String>,
441}
442
443/// Conversion helper from a raw list-query row to a `RenderView`. Defined
444/// as a trait so we can move the bulky destructuring out of the main loop
445/// without committing to a free function name in the module index.
446trait IntoView {
447    /// Convert a raw list-query row into a `RenderView`.
448    ///
449    /// # Errors
450    ///
451    /// Returns [`Error::Database`] if a UUID blob is malformed.
452    fn into_view(self) -> Result<RenderView, Error>;
453}
454
455impl IntoView for RenderListRow {
456    fn into_view(self) -> Result<RenderView, Error> {
457        let render_id = uuid_from_bytes(&self.render_id).ok_or_else(|| {
458            tracing::error!("bad render uuid");
459            Error::Database
460        })?;
461        let destination =
462            library::destination_from_columns(self.owner_user_id, self.owner_group_id)?;
463        let created_by = self
464            .created_by
465            .as_deref()
466            .map(uuid_from_bytes)
467            .map(|opt| {
468                opt.ok_or_else(|| {
469                    tracing::error!("bad created_by uuid");
470                    Error::Database
471                })
472            })
473            .transpose()?;
474        let notecard_id = self
475            .notecard_id
476            .as_deref()
477            .map(uuid_from_bytes)
478            .map(|opt| {
479                opt.ok_or_else(|| {
480                    tracing::error!("bad notecard uuid");
481                    Error::Database
482                })
483            })
484            .transpose()?;
485        let glw_data_id = self
486            .glw_data_id
487            .as_deref()
488            .map(uuid_from_bytes)
489            .map(|opt| {
490                opt.ok_or_else(|| {
491                    tracing::error!("bad glw_data uuid");
492                    Error::Database
493                })
494            })
495            .transpose()?;
496        Ok(RenderView {
497            render_id,
498            destination,
499            created_by,
500            created_by_username: self.creator_username,
501            created_by_legacy_name: self.creator_legacy_name,
502            notecard_id,
503            notecard_name: self.notecard_name,
504            kind: self.kind,
505            status: self.status,
506            error_message: self.error_message,
507            created_at: self.created_at,
508            finished_at: self.finished_at,
509            has_without_route: self.image_without_route_filename.is_some(),
510            content_type: self.content_type,
511            lower_left_x: self.lower_left_x.and_then(|v| u16::try_from(v).ok()),
512            lower_left_y: self.lower_left_y.and_then(|v| u16::try_from(v).ok()),
513            upper_right_x: self.upper_right_x.and_then(|v| u16::try_from(v).ok()),
514            upper_right_y: self.upper_right_y.and_then(|v| u16::try_from(v).ok()),
515            glw_data_id,
516            glw_data_name: self.glw_data_name,
517        })
518    }
519}
520
521/// Look up a user's display fields for view-building.
522async fn lookup_user_names(state: &AppState, user_id: Uuid) -> Result<(String, String), Error> {
523    let row: Option<(String, String)> =
524        sqlx::query_as("SELECT username, legacy_name FROM users WHERE user_id = ?1")
525            .bind(user_id.as_bytes().to_vec())
526            .fetch_optional(&state.db)
527            .await
528            .map_err(|err| {
529                tracing::error!("user name lookup failed: {err}");
530                Error::Database
531            })?;
532    row.ok_or_else(|| Error::NotFound(format!("user {user_id}")))
533}
534
535/// Look up a saved notecard's display name. Returns `None` if the row
536/// has been deleted out from under us (which the `ON DELETE RESTRICT` FK
537/// on `saved_renders.notecard_id` should prevent in normal operation).
538async fn lookup_notecard_name(
539    state: &AppState,
540    notecard_id: Uuid,
541) -> Result<Option<String>, Error> {
542    let row: Option<(String,)> =
543        sqlx::query_as("SELECT name FROM saved_notecards WHERE notecard_id = ?1")
544            .bind(notecard_id.as_bytes().to_vec())
545            .fetch_optional(&state.db)
546            .await
547            .map_err(|err| {
548                tracing::error!("notecard name lookup failed: {err}");
549                Error::Database
550            })?;
551    Ok(row.map(|(name,)| name))
552}
553
554/// Look up a saved GLW data row's display name. Returns `None` if
555/// the row has been deleted out from under us (which the
556/// `ON DELETE RESTRICT` FK should prevent in normal operation).
557async fn lookup_glw_data_name(
558    state: &AppState,
559    glw_data_id: Uuid,
560) -> Result<Option<String>, Error> {
561    let row: Option<(String,)> =
562        sqlx::query_as("SELECT name FROM saved_glw_data WHERE glw_data_id = ?1")
563            .bind(glw_data_id.as_bytes().to_vec())
564            .fetch_optional(&state.db)
565            .await
566            .map_err(|err| {
567                tracing::error!("glw data name lookup failed: {err}");
568                Error::Database
569            })?;
570    Ok(row.map(|(name,)| name))
571}
572
573/// SQL: list a user's personal renders.
574const LIST_PERSONAL_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
575        r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
576        r.error_message, r.image_without_route_filename, r.content_type, \
577        r.created_at, r.finished_at, \
578        r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
579        r.glw_data_id, g.name AS glw_data_name \
580     FROM saved_renders AS r \
581     LEFT JOIN users AS u ON u.user_id = r.created_by \
582     LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
583     LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
584     WHERE r.owner_user_id = ?1 \
585     ORDER BY r.created_at DESC";
586
587/// SQL: list a user's personal renders filtered by status.
588const LIST_PERSONAL_STATUS_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
589        r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
590        r.error_message, r.image_without_route_filename, r.content_type, \
591        r.created_at, r.finished_at, \
592        r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
593        r.glw_data_id, g.name AS glw_data_name \
594     FROM saved_renders AS r \
595     LEFT JOIN users AS u ON u.user_id = r.created_by \
596     LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
597     LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
598     WHERE r.owner_user_id = ?1 AND r.status = ?2 \
599     ORDER BY r.created_at DESC";
600
601/// SQL: list a group's renders. The JOIN against `group_memberships` and the
602/// `(gm.role = 'owner' OR r.status = 'done')` clause fold both the membership
603/// check and the member-only-finished rule into SQL so a forgotten
604/// `assert_can_view` call cannot leak rows.
605const LIST_GROUP_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
606        r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
607        r.error_message, r.image_without_route_filename, r.content_type, \
608        r.created_at, r.finished_at, \
609        r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
610        r.glw_data_id, g.name AS glw_data_name \
611     FROM saved_renders AS r \
612     LEFT JOIN users AS u ON u.user_id = r.created_by \
613     JOIN group_memberships AS gm \
614       ON gm.group_id = r.owner_group_id AND gm.user_id = ?2 \
615     LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
616     LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
617     WHERE r.owner_group_id = ?1 \
618       AND (gm.role = 'owner' OR r.status = 'done') \
619     ORDER BY r.created_at DESC";
620
621/// SQL: list a group's renders filtered by status. Same membership and
622/// visibility folding as `LIST_GROUP_SQL`.
623const LIST_GROUP_STATUS_SQL: &str = "SELECT r.render_id, r.owner_user_id, r.owner_group_id, \
624        r.created_by, u.username AS creator_username, u.legacy_name AS creator_legacy_name, r.notecard_id, n.name AS notecard_name, r.kind, r.status, \
625        r.error_message, r.image_without_route_filename, r.content_type, \
626        r.created_at, r.finished_at, \
627        r.lower_left_x, r.lower_left_y, r.upper_right_x, r.upper_right_y, \
628        r.glw_data_id, g.name AS glw_data_name \
629     FROM saved_renders AS r \
630     LEFT JOIN users AS u ON u.user_id = r.created_by \
631     JOIN group_memberships AS gm \
632       ON gm.group_id = r.owner_group_id AND gm.user_id = ?2 \
633     LEFT JOIN saved_notecards AS n ON n.notecard_id = r.notecard_id \
634     LEFT JOIN saved_glw_data AS g ON g.glw_data_id = r.glw_data_id \
635     WHERE r.owner_group_id = ?1 AND r.status = ?3 \
636       AND (gm.role = 'owner' OR r.status = 'done') \
637     ORDER BY r.created_at DESC";