Skip to main content

max / makenotwork

6.7 KB · 207 lines History Blame Raw
1 //! Inline edit forms, export, and account deletion pages.
2
3 use axum::extract::{Path, Query, State};
4 use axum::response::IntoResponse;
5 use serde::Deserialize;
6 use tower_sessions::Session;
7
8 use crate::{
9 auth::AuthUser,
10 db::{self, BlogPostId, ItemId, Slug},
11 error::{AppError, Result},
12 helpers::get_csrf_token,
13 templates::{
14 BlogEditorTemplate, DeleteAccountTemplate, ExportPortalTemplate, ImportPortalTemplate,
15 ItemEditRowTemplate,
16 },
17 types::ContentItem,
18 };
19 use sqlx::PgPool;
20
21 /// Render an inline edit row for an item in the project content table.
22 #[tracing::instrument(skip_all, name = "dashboard_forms::item_edit_row")]
23 pub(super) async fn item_edit_row(
24 State(db): State<PgPool>,
25 AuthUser(session_user): AuthUser,
26 Path(id): Path<String>,
27 ) -> Result<impl IntoResponse> {
28 let item_id: ItemId = id.parse().map_err(|_| AppError::NotFound)?;
29
30 let db_item = db::items::get_item_by_id(&db, item_id)
31 .await?
32 .ok_or(AppError::NotFound)?;
33
34 let db_project = db::projects::get_project_by_id(&db, db_item.project_id)
35 .await?
36 .ok_or(AppError::NotFound)?;
37
38 if db_project.user_id != session_user.id {
39 return Err(AppError::Forbidden);
40 }
41
42 let item = ContentItem::from_db(&db_item, 0);
43
44 Ok(ItemEditRowTemplate { item })
45 }
46
47 /// Render the data export portal page.
48 #[tracing::instrument(skip_all, name = "dashboard_forms::export_portal")]
49 pub(super) async fn export_portal(
50 State(db): State<PgPool>,
51 session: Session,
52 AuthUser(session_user): AuthUser,
53 ) -> Result<impl IntoResponse> {
54 let csrf_token = get_csrf_token(&session).await;
55
56 // Check if user has any S3 content (items, versions, insertions)
57 let items = db::items::get_items_by_user(&db, session_user.id).await?;
58 let has_item_content = items.iter().any(crate::db::DbItem::has_s3_content);
59
60 // Sum known file sizes from versions and insertions
61 let known_size = db::creator_tiers::get_user_content_size(&db, session_user.id).await?;
62 let has_content = has_item_content || known_size > 0;
63
64 let content_size = if !has_content {
65 "No files".to_string()
66 } else if has_item_content && known_size > 0 {
67 format!(
68 "{} + audio/cover files",
69 crate::helpers::format_file_size(known_size)
70 )
71 } else if known_size > 0 {
72 crate::helpers::format_file_size(known_size)
73 } else {
74 "Audio/cover files".to_string()
75 };
76
77 Ok(ExportPortalTemplate {
78 csrf_token,
79 session_user: Some(session_user),
80 has_content,
81 content_size,
82 })
83 }
84
85 /// Render the data import portal page.
86 #[tracing::instrument(skip_all, name = "dashboard_forms::import_portal")]
87 pub(super) async fn import_portal(
88 State(db): State<PgPool>,
89 session: Session,
90 AuthUser(session_user): AuthUser,
91 ) -> Result<impl IntoResponse> {
92 let csrf_token = get_csrf_token(&session).await;
93
94 let db_projects = db::projects::get_projects_by_user(&db, session_user.id).await?;
95 let projects: Vec<crate::templates::ImportProjectOption> = db_projects
96 .into_iter()
97 .map(|p| crate::templates::ImportProjectOption {
98 id: p.id.to_string(),
99 title: p.title,
100 })
101 .collect();
102
103 let db_jobs = db::imports::list_import_jobs(&db, session_user.id).await?;
104 let jobs: Vec<crate::templates::ImportJobRow> = db_jobs
105 .into_iter()
106 .map(|j| crate::templates::ImportJobRow {
107 source: j.source.to_string(),
108 status: j.status.to_string(),
109 total_rows: j.total_rows,
110 created_rows: j.created_rows,
111 created_at: j.created_at,
112 })
113 .collect();
114
115 Ok(ImportPortalTemplate {
116 csrf_token,
117 session_user: Some(session_user),
118 projects,
119 jobs,
120 })
121 }
122
123 /// Render the account deletion confirmation page.
124 #[tracing::instrument(skip_all, name = "dashboard_forms::delete_account_page")]
125 pub(super) async fn delete_account_page(
126 session: Session,
127 AuthUser(session_user): AuthUser,
128 ) -> Result<impl IntoResponse> {
129 let csrf_token = get_csrf_token(&session).await;
130
131 Ok(DeleteAccountTemplate {
132 csrf_token,
133 session_user: Some(session_user.clone()),
134 username: session_user.username.to_string(),
135 })
136 }
137
138 /// Query parameters for the blog editor page.
139 #[derive(Deserialize)]
140 pub(super) struct BlogEditorQuery {
141 pub post: Option<String>,
142 }
143
144 /// Render the blog post editor page (create new or edit existing).
145 #[tracing::instrument(skip_all, name = "dashboard_forms::blog_editor")]
146 pub(super) async fn blog_editor(
147 State(db): State<PgPool>,
148 session: Session,
149 AuthUser(session_user): AuthUser,
150 Path(slug): Path<String>,
151 Query(query): Query<BlogEditorQuery>,
152 ) -> Result<impl IntoResponse> {
153 let slug_val = Slug::new(&slug).map_err(|_| AppError::NotFound)?;
154 let db_project = db::projects::get_project_by_user_and_slug(&db, session_user.id, &slug_val)
155 .await?
156 .ok_or(AppError::NotFound)?;
157
158 let csrf_token = get_csrf_token(&session).await;
159
160 // The landing "Show on landing" control only applies to the platform
161 // changelog project; surfacing it elsewhere would expose an inert flag.
162 let is_changelog_project = db_project.slug.as_str() == crate::constants::CHANGELOG_PROJECT_SLUG;
163
164 // If editing an existing post, load it
165 if let Some(post_id_str) = &query.post {
166 let post_id: BlogPostId = post_id_str.parse().map_err(|_| AppError::NotFound)?;
167 let post = db::blog_posts::get_blog_post_by_id(&db, post_id)
168 .await?
169 .ok_or(AppError::NotFound)?;
170
171 if post.project_id != db_project.id {
172 return Err(AppError::NotFound);
173 }
174
175 return Ok(BlogEditorTemplate {
176 csrf_token,
177 session_user: Some(session_user),
178 project_id: db_project.id.to_string(),
179 project_slug: db_project.slug.to_string(),
180 editing: true,
181 post_id: post.id.to_string(),
182 post_title: post.title,
183 post_slug: post.slug.to_string(),
184 post_body: post.body_markdown,
185 post_is_published: post.published_at.is_some(),
186 is_changelog_project,
187 post_show_on_landing: post.show_on_landing,
188 });
189 }
190
191 // New post
192 Ok(BlogEditorTemplate {
193 csrf_token,
194 session_user: Some(session_user),
195 project_id: db_project.id.to_string(),
196 project_slug: db_project.slug.to_string(),
197 editing: false,
198 post_id: String::new(),
199 post_title: String::new(),
200 post_slug: String::new(),
201 post_body: String::new(),
202 post_is_published: false,
203 is_changelog_project,
204 post_show_on_landing: false,
205 })
206 }
207