max / makenotwork
19 files changed,
+1046 insertions,
-3 deletions
| @@ -3465,7 +3465,7 @@ dependencies = [ | |||
| 3465 | 3465 | ||
| 3466 | 3466 | [[package]] | |
| 3467 | 3467 | name = "makenotwork" | |
| 3468 | - | version = "0.5.15" | |
| 3468 | + | version = "0.5.17" | |
| 3469 | 3469 | dependencies = [ | |
| 3470 | 3470 | "anyhow", | |
| 3471 | 3471 | "argon2", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.5.16" | |
| 3 | + | version = "0.5.17" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | -- Project sections: tabbed markdown content blocks within projects. | |
| 2 | + | -- Mirrors item_sections (migration 054) but scoped to projects, for content | |
| 3 | + | -- that applies across all platform releases (privacy policy, terms, FAQ, etc). | |
| 4 | + | CREATE TABLE project_sections ( | |
| 5 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 6 | + | project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, | |
| 7 | + | title VARCHAR(100) NOT NULL, | |
| 8 | + | slug VARCHAR(120) NOT NULL, | |
| 9 | + | body TEXT NOT NULL DEFAULT '', | |
| 10 | + | sort_order INTEGER NOT NULL DEFAULT 0, | |
| 11 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 12 | + | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 13 | + | ); | |
| 14 | + | ||
| 15 | + | CREATE UNIQUE INDEX idx_project_sections_project_slug ON project_sections(project_id, slug); | |
| 16 | + | CREATE INDEX idx_project_sections_project_order ON project_sections(project_id, sort_order); |
| @@ -176,6 +176,7 @@ define_pg_uuid_id!( | |||
| 176 | 176 | MailingListSubscriberId, | |
| 177 | 177 | CustomDomainId, | |
| 178 | 178 | ItemSectionId, | |
| 179 | + | ProjectSectionId, | |
| 179 | 180 | ImportJobId, | |
| 180 | 181 | MediaFileId, | |
| 181 | 182 | TipId, |
| @@ -14,6 +14,7 @@ pub mod items; | |||
| 14 | 14 | pub mod versions; | |
| 15 | 15 | pub(crate) mod chapters; | |
| 16 | 16 | pub(crate) mod item_sections; | |
| 17 | + | pub(crate) mod project_sections; | |
| 17 | 18 | pub mod transactions; | |
| 18 | 19 | pub(crate) mod discover; | |
| 19 | 20 | pub(crate) mod custom_links; |
| @@ -96,3 +96,24 @@ pub struct DbProjectWithItemCount { | |||
| 96 | 96 | /// Number of public items in this project. | |
| 97 | 97 | pub item_count: i64, | |
| 98 | 98 | } | |
| 99 | + | ||
| 100 | + | /// A tabbed content section within a project. | |
| 101 | + | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 102 | + | pub struct DbProjectSection { | |
| 103 | + | /// Database primary key. | |
| 104 | + | pub id: ProjectSectionId, | |
| 105 | + | /// Parent project ID. | |
| 106 | + | pub project_id: ProjectId, | |
| 107 | + | /// Section tab title. | |
| 108 | + | pub title: String, | |
| 109 | + | /// URL-safe slug (unique per project). | |
| 110 | + | pub slug: String, | |
| 111 | + | /// Markdown body content. | |
| 112 | + | pub body: String, | |
| 113 | + | /// Display order among sibling sections. | |
| 114 | + | pub sort_order: i32, | |
| 115 | + | /// When this section was created. | |
| 116 | + | pub created_at: DateTime<Utc>, | |
| 117 | + | /// When this section was last modified. | |
| 118 | + | pub updated_at: DateTime<Utc>, | |
| 119 | + | } |
| @@ -0,0 +1,127 @@ | |||
| 1 | + | //! Project section CRUD: tabbed markdown content blocks within projects. | |
| 2 | + | ||
| 3 | + | use sqlx::PgPool; | |
| 4 | + | ||
| 5 | + | use super::models::*; | |
| 6 | + | use super::{ProjectId, ProjectSectionId}; | |
| 7 | + | use crate::error::Result; | |
| 8 | + | ||
| 9 | + | /// List all sections for a project, ordered by sort_order. | |
| 10 | + | #[tracing::instrument(skip_all)] | |
| 11 | + | pub async fn list_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbProjectSection>> { | |
| 12 | + | let sections = sqlx::query_as::<_, DbProjectSection>( | |
| 13 | + | "SELECT * FROM project_sections WHERE project_id = $1 ORDER BY sort_order LIMIT 500", | |
| 14 | + | ) | |
| 15 | + | .bind(project_id) | |
| 16 | + | .fetch_all(pool) | |
| 17 | + | .await?; | |
| 18 | + | ||
| 19 | + | Ok(sections) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /// Fetch a section by primary key. Returns `None` if not found. | |
| 23 | + | #[tracing::instrument(skip_all)] | |
| 24 | + | pub async fn get_by_id(pool: &PgPool, section_id: ProjectSectionId) -> Result<Option<DbProjectSection>> { | |
| 25 | + | let section = sqlx::query_as::<_, DbProjectSection>( | |
| 26 | + | "SELECT * FROM project_sections WHERE id = $1", | |
| 27 | + | ) | |
| 28 | + | .bind(section_id) | |
| 29 | + | .fetch_optional(pool) | |
| 30 | + | .await?; | |
| 31 | + | ||
| 32 | + | Ok(section) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Insert a new section for a project. | |
| 36 | + | #[tracing::instrument(skip_all)] | |
| 37 | + | pub async fn create( | |
| 38 | + | pool: &PgPool, | |
| 39 | + | project_id: ProjectId, | |
| 40 | + | title: &str, | |
| 41 | + | slug: &str, | |
| 42 | + | body: &str, | |
| 43 | + | sort_order: i32, | |
| 44 | + | ) -> Result<DbProjectSection> { | |
| 45 | + | let section = sqlx::query_as::<_, DbProjectSection>( | |
| 46 | + | r#" | |
| 47 | + | INSERT INTO project_sections (project_id, title, slug, body, sort_order) | |
| 48 | + | VALUES ($1, $2, $3, $4, $5) | |
| 49 | + | RETURNING * | |
| 50 | + | "#, | |
| 51 | + | ) | |
| 52 | + | .bind(project_id) | |
| 53 | + | .bind(title) | |
| 54 | + | .bind(slug) | |
| 55 | + | .bind(body) | |
| 56 | + | .bind(sort_order) | |
| 57 | + | .fetch_one(pool) | |
| 58 | + | .await?; | |
| 59 | + | ||
| 60 | + | Ok(section) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Update a section's title, slug, and body. | |
| 64 | + | #[tracing::instrument(skip_all)] | |
| 65 | + | pub async fn update( | |
| 66 | + | pool: &PgPool, | |
| 67 | + | section_id: ProjectSectionId, | |
| 68 | + | title: &str, | |
| 69 | + | slug: &str, | |
| 70 | + | body: &str, | |
| 71 | + | ) -> Result<DbProjectSection> { | |
| 72 | + | let section = sqlx::query_as::<_, DbProjectSection>( | |
| 73 | + | r#" | |
| 74 | + | UPDATE project_sections | |
| 75 | + | SET title = $2, slug = $3, body = $4, updated_at = now() | |
| 76 | + | WHERE id = $1 | |
| 77 | + | RETURNING * | |
| 78 | + | "#, | |
| 79 | + | ) | |
| 80 | + | .bind(section_id) | |
| 81 | + | .bind(title) | |
| 82 | + | .bind(slug) | |
| 83 | + | .bind(body) | |
| 84 | + | .fetch_one(pool) | |
| 85 | + | .await?; | |
| 86 | + | ||
| 87 | + | Ok(section) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /// Permanently delete a section by ID. | |
| 91 | + | #[tracing::instrument(skip_all)] | |
| 92 | + | pub async fn delete(pool: &PgPool, section_id: ProjectSectionId) -> Result<()> { | |
| 93 | + | sqlx::query("DELETE FROM project_sections WHERE id = $1") | |
| 94 | + | .bind(section_id) | |
| 95 | + | .execute(pool) | |
| 96 | + | .await?; | |
| 97 | + | ||
| 98 | + | Ok(()) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Reorder sections by setting sort_order from an ordered list of IDs. | |
| 102 | + | #[tracing::instrument(skip_all)] | |
| 103 | + | pub async fn reorder(pool: &PgPool, project_id: ProjectId, section_ids: &[ProjectSectionId]) -> Result<()> { | |
| 104 | + | for (i, id) in section_ids.iter().enumerate() { | |
| 105 | + | sqlx::query( | |
| 106 | + | "UPDATE project_sections SET sort_order = $1, updated_at = now() WHERE id = $2 AND project_id = $3", | |
| 107 | + | ) | |
| 108 | + | .bind(i as i32) | |
| 109 | + | .bind(id) | |
| 110 | + | .bind(project_id) | |
| 111 | + | .execute(pool) | |
| 112 | + | .await?; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | Ok(()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Count sections for a project. | |
| 119 | + | #[tracing::instrument(skip_all)] | |
| 120 | + | pub async fn count_by_project(pool: &PgPool, project_id: ProjectId) -> Result<i64> { | |
| 121 | + | let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM project_sections WHERE project_id = $1") | |
| 122 | + | .bind(project_id) | |
| 123 | + | .fetch_one(pool) | |
| 124 | + | .await?; | |
| 125 | + | ||
| 126 | + | Ok(row.0) | |
| 127 | + | } |
| @@ -27,6 +27,7 @@ pub(crate) mod license_keys; | |||
| 27 | 27 | mod links; | |
| 28 | 28 | mod passkeys; | |
| 29 | 29 | mod projects; | |
| 30 | + | mod project_sections; | |
| 30 | 31 | mod subscriptions; | |
| 31 | 32 | mod tags; | |
| 32 | 33 | pub(crate) mod totp; | |
| @@ -264,6 +265,11 @@ pub fn api_routes() -> Router<AppState> { | |||
| 264 | 265 | .route("/api/sections/{id}", put(items::update_section)) | |
| 265 | 266 | .route("/api/sections/{id}", delete(items::delete_section)) | |
| 266 | 267 | .route("/api/items/{id}/sections/reorder", put(items::reorder_sections)) | |
| 268 | + | // Project section routes | |
| 269 | + | .route("/api/projects/{id}/sections", post(project_sections::create_section)) | |
| 270 | + | .route("/api/project-sections/{id}", put(project_sections::update_section)) | |
| 271 | + | .route("/api/project-sections/{id}", delete(project_sections::delete_section)) | |
| 272 | + | .route("/api/projects/{id}/sections/reorder", put(project_sections::reorder_sections)) | |
| 267 | 273 | // Library routes | |
| 268 | 274 | .route("/api/library/add/{item_id}", post(users::add_to_library)) | |
| 269 | 275 | .route("/api/library/remove/{item_id}", delete(users::remove_from_library)) | |
| @@ -393,6 +399,7 @@ pub fn api_routes() -> Router<AppState> { | |||
| 393 | 399 | .route("/api/items/{id}/versions", get(items::list_versions)) | |
| 394 | 400 | .route("/api/items/{id}/chapters", get(items::list_chapters)) | |
| 395 | 401 | .route("/api/items/{id}/sections", get(items::list_sections)) | |
| 402 | + | .route("/api/projects/{id}/sections", get(project_sections::list_sections)) | |
| 396 | 403 | .route("/api/projects/{id}/blog", get(blog::list_blog_posts)) | |
| 397 | 404 | .route("/api/blog/{id}", get(blog::get_blog_post)) | |
| 398 | 405 | .route("/api/tags/search", get(tags::search_tags)) |
| @@ -0,0 +1,198 @@ | |||
| 1 | + | //! Project section handlers: tabbed markdown content blocks on projects | |
| 2 | + | //! (privacy policy, terms, FAQ, etc — shared across all platform releases). | |
| 3 | + | ||
| 4 | + | use axum::{ | |
| 5 | + | extract::{Path, State}, | |
| 6 | + | http::{header::HeaderMap, StatusCode}, | |
| 7 | + | response::{IntoResponse, Response}, | |
| 8 | + | Json, | |
| 9 | + | }; | |
| 10 | + | use serde::{Deserialize, Serialize}; | |
| 11 | + | ||
| 12 | + | use crate::{ | |
| 13 | + | auth::AuthUser, | |
| 14 | + | db::{self, ProjectId, ProjectSectionId}, | |
| 15 | + | error::{AppError, Result}, | |
| 16 | + | helpers::{htmx_toast_response, is_htmx_request, slugify}, | |
| 17 | + | types::ListResponse, | |
| 18 | + | validation, | |
| 19 | + | AppState, | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | use super::verify_project_ownership; | |
| 23 | + | ||
| 24 | + | /// Maximum number of sections per project. | |
| 25 | + | const MAX_SECTIONS_PER_PROJECT: i64 = 10; | |
| 26 | + | ||
| 27 | + | #[derive(Debug, Deserialize)] | |
| 28 | + | pub struct CreateSectionRequest { | |
| 29 | + | pub title: String, | |
| 30 | + | #[serde(default)] | |
| 31 | + | pub body: String, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | #[derive(Debug, Deserialize)] | |
| 35 | + | pub struct UpdateSectionRequest { | |
| 36 | + | pub title: String, | |
| 37 | + | #[serde(default)] | |
| 38 | + | pub body: String, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | #[derive(Debug, Deserialize)] | |
| 42 | + | pub struct ReorderSectionsRequest { | |
| 43 | + | pub section_ids: Vec<ProjectSectionId>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | #[derive(Debug, Serialize)] | |
| 47 | + | struct SectionResponse { | |
| 48 | + | id: ProjectSectionId, | |
| 49 | + | project_id: ProjectId, | |
| 50 | + | title: String, | |
| 51 | + | slug: String, | |
| 52 | + | body: String, | |
| 53 | + | sort_order: i32, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl From<db::DbProjectSection> for SectionResponse { | |
| 57 | + | fn from(s: db::DbProjectSection) -> Self { | |
| 58 | + | Self { | |
| 59 | + | id: s.id, | |
| 60 | + | project_id: s.project_id, | |
| 61 | + | title: s.title, | |
| 62 | + | slug: s.slug, | |
| 63 | + | body: s.body, | |
| 64 | + | sort_order: s.sort_order, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | #[tracing::instrument(skip_all, name = "projects::create_section")] | |
| 70 | + | pub(super) async fn create_section( | |
| 71 | + | State(state): State<AppState>, | |
| 72 | + | AuthUser(user): AuthUser, | |
| 73 | + | Path(project_id): Path<ProjectId>, | |
| 74 | + | Json(req): Json<CreateSectionRequest>, | |
| 75 | + | ) -> Result<impl IntoResponse> { | |
| 76 | + | user.check_not_suspended()?; | |
| 77 | + | let title = req.title.trim().to_string(); | |
| 78 | + | validation::validate_section_title(&title)?; | |
| 79 | + | validation::validate_section_body(&req.body)?; | |
| 80 | + | ||
| 81 | + | verify_project_ownership(&state, project_id, user.id).await?; | |
| 82 | + | ||
| 83 | + | let count = db::project_sections::count_by_project(&state.db, project_id).await?; | |
| 84 | + | if count >= MAX_SECTIONS_PER_PROJECT { | |
| 85 | + | return Err(AppError::Validation(format!( | |
| 86 | + | "Maximum of {} sections per project", | |
| 87 | + | MAX_SECTIONS_PER_PROJECT | |
| 88 | + | ))); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | let slug = slugify(&title).to_string(); | |
| 92 | + | let sort_order = count as i32; | |
| 93 | + | ||
| 94 | + | let section = db::project_sections::create( | |
| 95 | + | &state.db, | |
| 96 | + | project_id, | |
| 97 | + | &title, | |
| 98 | + | &slug, | |
| 99 | + | &req.body, | |
| 100 | + | sort_order, | |
| 101 | + | ) | |
| 102 | + | .await?; | |
| 103 | + | ||
| 104 | + | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 105 | + | ||
| 106 | + | Ok(Json(SectionResponse::from(section))) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[tracing::instrument(skip_all, name = "projects::list_sections")] | |
| 110 | + | pub(super) async fn list_sections( | |
| 111 | + | State(state): State<AppState>, | |
| 112 | + | Path(project_id): Path<ProjectId>, | |
| 113 | + | ) -> Result<impl IntoResponse> { | |
| 114 | + | let project = db::projects::get_project_by_id(&state.db, project_id) | |
| 115 | + | .await? | |
| 116 | + | .ok_or(AppError::NotFound)?; | |
| 117 | + | if !project.is_public { | |
| 118 | + | return Err(AppError::NotFound); | |
| 119 | + | } | |
| 120 | + | let sections = db::project_sections::list_by_project(&state.db, project_id).await?; | |
| 121 | + | let data: Vec<SectionResponse> = sections.into_iter().map(SectionResponse::from).collect(); | |
| 122 | + | Ok(Json(ListResponse { data })) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[tracing::instrument(skip_all, name = "projects::update_section")] | |
| 126 | + | pub(super) async fn update_section( | |
| 127 | + | State(state): State<AppState>, | |
| 128 | + | AuthUser(user): AuthUser, | |
| 129 | + | Path(section_id): Path<ProjectSectionId>, | |
| 130 | + | Json(req): Json<UpdateSectionRequest>, | |
| 131 | + | ) -> Result<impl IntoResponse> { | |
| 132 | + | user.check_not_suspended()?; | |
| 133 | + | let title = req.title.trim().to_string(); | |
| 134 | + | validation::validate_section_title(&title)?; | |
| 135 | + | validation::validate_section_body(&req.body)?; | |
| 136 | + | ||
| 137 | + | let section = db::project_sections::get_by_id(&state.db, section_id) | |
| 138 | + | .await? | |
| 139 | + | .ok_or(AppError::NotFound)?; | |
| 140 | + | ||
| 141 | + | verify_project_ownership(&state, section.project_id, user.id).await?; | |
| 142 | + | ||
| 143 | + | let slug = slugify(&title).to_string(); | |
| 144 | + | ||
| 145 | + | let updated = db::project_sections::update( | |
| 146 | + | &state.db, | |
| 147 | + | section_id, | |
| 148 | + | &title, | |
| 149 | + | &slug, | |
| 150 | + | &req.body, | |
| 151 | + | ) | |
| 152 | + | .await?; | |
| 153 | + | ||
| 154 | + | db::projects::bump_cache_generation(&state.db, section.project_id).await?; | |
| 155 | + | ||
| 156 | + | Ok(Json(SectionResponse::from(updated))) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[tracing::instrument(skip_all, name = "projects::delete_section")] | |
| 160 | + | pub(super) async fn delete_section( | |
| 161 | + | State(state): State<AppState>, | |
| 162 | + | headers: HeaderMap, | |
| 163 | + | AuthUser(user): AuthUser, | |
| 164 | + | Path(section_id): Path<ProjectSectionId>, | |
| 165 | + | ) -> Result<Response> { | |
| 166 | + | user.check_not_suspended()?; | |
| 167 | + | ||
| 168 | + | let section = db::project_sections::get_by_id(&state.db, section_id) | |
| 169 | + | .await? | |
| 170 | + | .ok_or(AppError::NotFound)?; | |
| 171 | + | ||
| 172 | + | verify_project_ownership(&state, section.project_id, user.id).await?; | |
| 173 | + | ||
| 174 | + | db::project_sections::delete(&state.db, section_id).await?; | |
| 175 | + | db::projects::bump_cache_generation(&state.db, section.project_id).await?; | |
| 176 | + | ||
| 177 | + | if is_htmx_request(&headers) { | |
| 178 | + | return Ok(htmx_toast_response("Section deleted", "success").into_response()); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | #[tracing::instrument(skip_all, name = "projects::reorder_sections")] | |
| 185 | + | pub(super) async fn reorder_sections( | |
| 186 | + | State(state): State<AppState>, | |
| 187 | + | AuthUser(user): AuthUser, | |
| 188 | + | Path(project_id): Path<ProjectId>, | |
| 189 | + | Json(req): Json<ReorderSectionsRequest>, | |
| 190 | + | ) -> Result<impl IntoResponse> { | |
| 191 | + | user.check_not_suspended()?; | |
| 192 | + | verify_project_ownership(&state, project_id, user.id).await?; | |
| 193 | + | ||
| 194 | + | db::project_sections::reorder(&state.db, project_id, &req.section_ids).await?; | |
| 195 | + | db::projects::bump_cache_generation(&state.db, project_id).await?; | |
| 196 | + | ||
| 197 | + | Ok(StatusCode::NO_CONTENT) | |
| 198 | + | } |
| @@ -334,8 +334,9 @@ pub(super) async fn project_tab_settings( | |||
| 334 | 334 | ||
| 335 | 335 | let features = db_project.features.clone(); | |
| 336 | 336 | let project_features = db::ProjectFeature::all(); | |
| 337 | + | let sections = db::project_sections::list_by_project(&state.db, db_project.id).await?; | |
| 337 | 338 | ||
| 338 | - | Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_id, features, project_features })) | |
| 339 | + | Ok(helpers::with_etag(generation, ProjectSettingsTabTemplate { project, category_name, project_id, features, project_features, sections })) | |
| 339 | 340 | } | |
| 340 | 341 | ||
| 341 | 342 | /// Render the HTMX partial for the project subscriptions tab (tier management). |
| @@ -192,6 +192,13 @@ pub(crate) async fn render_project_page( | |||
| 192 | 192 | .as_ref() | |
| 193 | 193 | .is_some_and(|u| u.id == db_project.user_id); | |
| 194 | 194 | ||
| 195 | + | let db_sections = db::project_sections::list_by_project(&state.db, db_project.id).await?; | |
| 196 | + | let cdn_base = state.config.cdn_base_url.as_deref().unwrap_or("https://cdn.makenot.work"); | |
| 197 | + | let sections: Vec<ProjectSection> = db_sections | |
| 198 | + | .iter() | |
| 199 | + | .map(|s| ProjectSection::from_db(s, db_project.user_id, cdn_base)) | |
| 200 | + | .collect(); | |
| 201 | + | ||
| 195 | 202 | Ok(ProjectTemplate { | |
| 196 | 203 | csrf_token, | |
| 197 | 204 | session_user: maybe_user, | |
| @@ -211,6 +218,7 @@ pub(crate) async fn render_project_page( | |||
| 211 | 218 | creator_id: db_user.id.to_string(), | |
| 212 | 219 | tip_project_id: Some(db_project.id.to_string()), | |
| 213 | 220 | is_owner, | |
| 221 | + | sections, | |
| 214 | 222 | } | |
| 215 | 223 | .into_response()) | |
| 216 | 224 | } |
| @@ -362,6 +362,8 @@ pub struct ProjectSettingsTabTemplate { | |||
| 362 | 362 | pub features: Vec<String>, | |
| 363 | 363 | /// All available features as (value, label, description) tuples. | |
| 364 | 364 | pub project_features: &'static [(&'static str, &'static str, &'static str)], | |
| 365 | + | /// Tabbed markdown sections (privacy policy, terms, FAQ, etc). | |
| 366 | + | pub sections: Vec<crate::db::DbProjectSection>, | |
| 365 | 367 | } | |
| 366 | 368 | ||
| 367 | 369 | /// Dashboard code tab partial (git repos management). |
| @@ -248,6 +248,8 @@ pub struct ProjectTemplate { | |||
| 248 | 248 | pub tip_project_id: Option<String>, | |
| 249 | 249 | /// Whether the current viewer owns this project. | |
| 250 | 250 | pub is_owner: bool, | |
| 251 | + | /// Tabbed markdown sections (privacy, terms, FAQ, etc). | |
| 252 | + | pub sections: Vec<crate::types::ProjectSection>, | |
| 251 | 253 | } | |
| 252 | 254 | ||
| 253 | 255 | /// Project paywall landing page (shown when a project requires purchase/subscription). |
| @@ -273,6 +273,31 @@ impl ItemSection { | |||
| 273 | 273 | } | |
| 274 | 274 | } | |
| 275 | 275 | ||
| 276 | + | /// A tabbed content section within a project, with pre-rendered HTML. | |
| 277 | + | #[derive(Clone)] | |
| 278 | + | pub struct ProjectSection { | |
| 279 | + | pub id: String, | |
| 280 | + | pub title: String, | |
| 281 | + | pub slug: String, | |
| 282 | + | pub body: String, | |
| 283 | + | pub body_html: String, | |
| 284 | + | pub sort_order: i32, | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | impl ProjectSection { | |
| 288 | + | /// Create from a DB row with media URL resolution for public-facing pages. | |
| 289 | + | pub fn from_db(s: &crate::db::DbProjectSection, user_id: crate::db::UserId, cdn_base: &str) -> Self { | |
| 290 | + | ProjectSection { | |
| 291 | + | id: s.id.to_string(), | |
| 292 | + | title: s.title.clone(), | |
| 293 | + | slug: s.slug.clone(), | |
| 294 | + | body: s.body.clone(), | |
| 295 | + | body_html: crate::markdown::render_creator_markdown(&s.body, user_id, cdn_base), | |
| 296 | + | sort_order: s.sort_order, | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 276 | 301 | /// Chapter/timestamp for audio content | |
| 277 | 302 | #[derive(Clone)] | |
| 278 | 303 | pub struct Chapter { |
| @@ -0,0 +1,161 @@ | |||
| 1 | + | // Project sections (Pages) editor — markdown content blocks for a project. | |
| 2 | + | // Mirrors item sections but scoped to projects. Loaded by the Settings tab. | |
| 3 | + | (function() { | |
| 4 | + | 'use strict'; | |
| 5 | + | ||
| 6 | + | function csrfHeaders() { | |
| 7 | + | var token = document.querySelector('meta[name="csrf-token"]'); | |
| 8 | + | return token ? { 'X-CSRF-Token': token.content } : {}; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | function escapeHtml(s) { | |
| 12 | + | var d = document.createElement('div'); | |
| 13 | + | d.textContent = s; | |
| 14 | + | return d.innerHTML; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | function showToast(msg) { | |
| 18 | + | if (window.showToast) { window.showToast(msg); return; } | |
| 19 | + | alert(msg); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | function bodyFor(id) { | |
| 23 | + | var el = document.querySelector('textarea[data-body-for="' + id + '"]'); | |
| 24 | + | return el ? el.value : ''; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | function updateCount(delta) { | |
| 28 | + | var el = document.getElementById('psection-count'); | |
| 29 | + | if (el) el.textContent = parseInt(el.textContent || '0') + delta; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | function attachRowHandlers(row) { | |
| 33 | + | var delBtn = row.querySelector('.psection-del-btn'); | |
| 34 | + | var editBtn = row.querySelector('.psection-edit-btn'); | |
| 35 | + | ||
| 36 | + | delBtn.addEventListener('click', function() { | |
| 37 | + | var id = this.dataset.id; | |
| 38 | + | if (!confirm('Delete this page?')) return; | |
| 39 | + | fetch('/api/project-sections/' + id, { method: 'DELETE', headers: csrfHeaders() }) | |
| 40 | + | .then(function(res) { | |
| 41 | + | if (res.ok) { | |
| 42 | + | var hidden = document.querySelector('textarea[data-body-for="' + id + '"]'); | |
| 43 | + | if (hidden) hidden.remove(); | |
| 44 | + | row.remove(); | |
| 45 | + | updateCount(-1); | |
| 46 | + | } else { | |
| 47 | + | showToast('Failed to delete'); | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | .catch(function() { showToast('Failed to delete'); }); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | editBtn.addEventListener('click', function() { | |
| 54 | + | var id = this.dataset.id; | |
| 55 | + | var title = this.dataset.title || row.querySelector('span').textContent; | |
| 56 | + | document.getElementById('edit-psec-id').value = id; | |
| 57 | + | document.getElementById('edit-psec-title').value = title; | |
| 58 | + | document.getElementById('edit-psec-body').value = bodyFor(id); | |
| 59 | + | document.getElementById('psec-edit-status').textContent = ''; | |
| 60 | + | document.getElementById('psection-edit-modal').style.display = 'block'; | |
| 61 | + | }); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | function init() { | |
| 65 | + | var addBtn = document.getElementById('add-psec-btn'); | |
| 66 | + | if (!addBtn) return; | |
| 67 | + | var projectId = addBtn.dataset.projectId; | |
| 68 | + | ||
| 69 | + | addBtn.addEventListener('click', function() { | |
| 70 | + | var title = document.getElementById('new-psec-title').value.trim(); | |
| 71 | + | var body = document.getElementById('new-psec-body').value; | |
| 72 | + | var status = document.getElementById('psec-add-status'); | |
| 73 | + | if (!title) { status.textContent = 'Title is required'; return; } | |
| 74 | + | this.disabled = true; | |
| 75 | + | status.textContent = ''; | |
| 76 | + | ||
| 77 | + | fetch('/api/projects/' + projectId + '/sections', { | |
| 78 | + | method: 'POST', | |
| 79 | + | headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), | |
| 80 | + | body: JSON.stringify({ title: title, body: body }) | |
| 81 | + | }) | |
| 82 | + | .then(function(res) { | |
| 83 | + | if (!res.ok) return res.json().then(function(d) { throw new Error(d.error || 'Failed'); }); | |
| 84 | + | return res.json(); | |
| 85 | + | }) | |
| 86 | + | .then(function(sec) { | |
| 87 | + | var empty = document.getElementById('psections-empty'); | |
| 88 | + | if (empty) empty.remove(); | |
| 89 | + | var list = document.getElementById('psections-list'); | |
| 90 | + | var row = document.createElement('div'); | |
| 91 | + | row.className = 'psection-row'; | |
| 92 | + | row.dataset.id = sec.id; | |
| 93 | + | row.style.cssText = 'display:flex;align-items:center;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid var(--border);'; | |
| 94 | + | row.innerHTML = | |
| 95 | + | '<span style="flex:1;font-weight:bold;">' + escapeHtml(sec.title) + '</span>' + | |
| 96 | + | '<code style="font-size:0.75rem;opacity:0.6;">#section-' + escapeHtml(sec.slug) + '</code>' + | |
| 97 | + | '<span style="font-size:0.8rem;opacity:0.6;">' + (sec.body || '').length + ' chars</span>' + | |
| 98 | + | '<button type="button" class="secondary psection-edit-btn" data-id="' + sec.id + '" data-title="' + escapeHtml(sec.title) + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Edit</button>' + | |
| 99 | + | '<button type="button" class="secondary psection-del-btn" data-id="' + sec.id + '" style="padding:0.25rem 0.6rem;font-size:0.8rem;">Delete</button>'; | |
| 100 | + | list.appendChild(row); | |
| 101 | + | var hidden = document.createElement('textarea'); | |
| 102 | + | hidden.style.display = 'none'; | |
| 103 | + | hidden.dataset.bodyFor = sec.id; | |
| 104 | + | hidden.value = sec.body || ''; | |
| 105 | + | list.appendChild(hidden); | |
| 106 | + | attachRowHandlers(row); | |
| 107 | + | updateCount(1); | |
| 108 | + | document.getElementById('new-psec-title').value = ''; | |
| 109 | + | document.getElementById('new-psec-body').value = ''; | |
| 110 | + | document.getElementById('psection-add-details').removeAttribute('open'); | |
| 111 | + | }) | |
| 112 | + | .catch(function(err) { status.textContent = err.message; }) | |
| 113 | + | .finally(function() { addBtn.disabled = false; }); | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | document.getElementById('save-psec-btn').addEventListener('click', function() { | |
| 117 | + | var id = document.getElementById('edit-psec-id').value; | |
| 118 | + | var title = document.getElementById('edit-psec-title').value.trim(); | |
| 119 | + | var body = document.getElementById('edit-psec-body').value; | |
| 120 | + | var status = document.getElementById('psec-edit-status'); | |
| 121 | + | if (!title) { status.textContent = 'Title is required'; return; } | |
| 122 | + | this.disabled = true; | |
| 123 | + | ||
| 124 | + | fetch('/api/project-sections/' + id, { | |
| 125 | + | method: 'PUT', | |
| 126 | + | headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), | |
| 127 | + | body: JSON.stringify({ title: title, body: body }) | |
| 128 | + | }) | |
| 129 | + | .then(function(res) { | |
| 130 | + | if (!res.ok) return res.json().then(function(d) { throw new Error(d.error || 'Failed'); }); | |
| 131 | + | return res.json(); | |
| 132 | + | }) | |
| 133 | + | .then(function(sec) { | |
| 134 | + | var row = document.querySelector('.psection-row[data-id="' + id + '"]'); | |
| 135 | + | if (row) { | |
| 136 | + | row.querySelector('span[style*="font-weight"]').textContent = sec.title; | |
| 137 | + | row.querySelector('code').textContent = '#section-' + sec.slug; | |
| 138 | + | row.querySelector('span[style*="opacity"]').textContent = (sec.body || '').length + ' chars'; | |
| 139 | + | row.querySelector('.psection-edit-btn').dataset.title = sec.title; | |
| 140 | + | } | |
| 141 | + | var hidden = document.querySelector('textarea[data-body-for="' + id + '"]'); | |
| 142 | + | if (hidden) hidden.value = sec.body || ''; | |
| 143 | + | document.getElementById('psection-edit-modal').style.display = 'none'; | |
| 144 | + | }) | |
| 145 | + | .catch(function(err) { status.textContent = err.message; }) | |
| 146 | + | .finally(function() { document.getElementById('save-psec-btn').disabled = false; }); | |
| 147 | + | }); | |
| 148 | + | ||
| 149 | + | document.getElementById('cancel-psec-btn').addEventListener('click', function() { | |
| 150 | + | document.getElementById('psection-edit-modal').style.display = 'none'; | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | document.querySelectorAll('.psection-row').forEach(attachRowHandlers); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // HTMX swaps in the settings partial; bind on swap + on initial load. | |
| 157 | + | if (document.getElementById('add-psec-btn')) init(); | |
| 158 | + | document.body.addEventListener('htmx:afterSettle', function(e) { | |
| 159 | + | if (e.target && e.target.querySelector && e.target.querySelector('#add-psec-btn')) init(); | |
| 160 | + | }); | |
| 161 | + | })(); |
| @@ -68,6 +68,16 @@ | |||
| 68 | 68 | .store-footer a { color: var(--detail); } | |
| 69 | 69 | .item-content button.primary { width: 100%; margin-top: 1rem; } | |
| 70 | 70 | .item-content button.secondary { width: 100%; margin-top: 1rem; } | |
| 71 | + | .project-sections { background: var(--light-background); padding: 2rem; margin-bottom: 2rem; } | |
| 72 | + | .section-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; flex-wrap: wrap; } | |
| 73 | + | .section-tab { background: none; border: none; padding: 0.6rem 1.2rem; cursor: pointer; font-size: 0.95rem; opacity: 0.6; border-bottom: 2px solid transparent; font-family: var(--font-body); color: var(--text); } | |
| 74 | + | .section-tab.active { opacity: 1; border-bottom-color: var(--detail); } | |
| 75 | + | .section-tab:hover { opacity: 0.9; } | |
| 76 | + | .section-panel { display: none; } | |
| 77 | + | .section-panel.active { display: block; } | |
| 78 | + | .section-panel p { margin-bottom: 1rem; } | |
| 79 | + | .section-panel ul, .section-panel ol { margin-left: 1.5rem; margin-bottom: 1rem; } | |
| 80 | + | .section-panel li { margin-bottom: 0.5rem; } | |
| 71 | 81 | .tiers-section { margin-bottom: 3rem; } | |
| 72 | 82 | .tiers-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } | |
| 73 | 83 | .tier-card { background: var(--light-background); padding: 2rem; display: flex; flex-direction: column; } | |
| @@ -134,6 +144,41 @@ | |||
| 134 | 144 | </div> | |
| 135 | 145 | </header> | |
| 136 | 146 | ||
| 147 | + | {% if !sections.is_empty() %} | |
| 148 | + | <section class="project-sections"> | |
| 149 | + | <div class="section-tabs"> | |
| 150 | + | {% for section in sections %} | |
| 151 | + | <button class="section-tab{% if loop.first %} active{% endif %}" | |
| 152 | + | data-tab="section-{{ section.slug }}" | |
| 153 | + | onclick="switchSectionTab(this, 'section-{{ section.slug }}')">{{ section.title }}</button> | |
| 154 | + | {% endfor %} | |
| 155 | + | </div> | |
| 156 | + | {% for section in sections %} | |
| 157 | + | <div class="section-panel{% if loop.first %} active{% endif %}" id="section-{{ section.slug }}"> | |
| 158 | + | {{ section.body_html|safe }} | |
| 159 | + | </div> | |
| 160 | + | {% endfor %} | |
| 161 | + | </section> | |
| 162 | + | <script> | |
| 163 | + | function switchSectionTab(btn, panelId) { | |
| 164 | + | document.querySelectorAll('.project-sections .section-tab').forEach(function(t) { t.classList.remove('active'); }); | |
| 165 | + | document.querySelectorAll('.project-sections .section-panel').forEach(function(p) { p.classList.remove('active'); }); | |
| 166 | + | btn.classList.add('active'); | |
| 167 | + | var panel = document.getElementById(panelId); | |
| 168 | + | if (panel) panel.classList.add('active'); | |
| 169 | + | history.replaceState(null, '', '#' + panelId); | |
| 170 | + | } | |
| 171 | + | (function() { | |
| 172 | + | var hash = window.location.hash.replace('#', ''); | |
| 173 | + | if (hash) { | |
| 174 | + | var panel = document.getElementById(hash); | |
| 175 | + | var tab = document.querySelector('.project-sections [data-tab="' + hash + '"]'); | |
| 176 | + | if (panel && tab) switchSectionTab(tab, hash); | |
| 177 | + | } | |
| 178 | + | })(); | |
| 179 | + | </script> | |
| 180 | + | {% endif %} | |
| 181 | + | ||
| 137 | 182 | <section class="items-section"> | |
| 138 | 183 | <div class="items-header"> | |
| 139 | 184 | <h2 class="section-header">Available Items</h2> |
| @@ -131,6 +131,68 @@ | |||
| 131 | 131 | Delete Project | |
| 132 | 132 | </button> | |
| 133 | 133 | </div> | |
| 134 | + | ||
| 135 | + | <!-- Pages (project-level markdown sections) --> | |
| 136 | + | <details class="content-section" id="psections-management"{% if !sections.is_empty() %} open{% endif %} style="margin-top: 2rem;"> | |
| 137 | + | <summary style="cursor: pointer;"> | |
| 138 | + | <h2 style="display: inline;">Pages (<span id="psection-count">{{ sections.len() }}</span>)</h2> | |
| 139 | + | </summary> | |
| 140 | + | <p style="font-size: 0.85rem; opacity: 0.7; margin: 0.75rem 0 1rem;">Markdown pages that apply to your whole project — Privacy Policy, Terms, FAQ, etc. They appear as tabs on your public project page and are linkable via <code>#section-<slug></code>. Max 10.</p> | |
| 141 | + | ||
| 142 | + | <div id="psections-list"> | |
| 143 | + | {% if sections.is_empty() %} | |
| 144 | + | <p id="psections-empty" style="opacity: 0.7;">No pages yet. Common pages: Privacy Policy, Terms of Service, FAQ, Support.</p> | |
| 145 | + | {% else %} | |
| 146 | + | {% for section in sections %} | |
| 147 | + | <div class="psection-row" data-id="{{ section.id }}" style="display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);"> | |
| 148 | + | <span style="flex: 1; font-weight: bold;">{{ section.title }}</span> | |
| 149 | + | <code style="font-size: 0.75rem; opacity: 0.6;">#section-{{ section.slug }}</code> | |
| 150 | + | <span style="font-size: 0.8rem; opacity: 0.6;">{{ section.body.chars().count() }} chars</span> | |
| 151 | + | <button type="button" class="secondary psection-edit-btn" data-id="{{ section.id }}" | |
| 152 | + | data-title="{{ section.title }}" | |
| 153 | + | style="padding: 0.25rem 0.6rem; font-size: 0.8rem;">Edit</button> | |
| 154 | + | <button type="button" class="secondary psection-del-btn" data-id="{{ section.id }}" | |
| 155 | + | style="padding: 0.25rem 0.6rem; font-size: 0.8rem;">Delete</button> | |
| 156 | + | </div> | |
| 157 | + | <textarea data-body-for="{{ section.id }}" style="display: none;">{{ section.body }}</textarea> | |
| 158 | + | {% endfor %} | |
| 159 | + | {% endif %} | |
| 160 | + | </div> | |
| 161 | + | ||
| 162 | + | <details id="psection-add-details" style="margin-top: 1rem;"> | |
| 163 | + | <summary style="cursor: pointer; font-size: 0.95rem;">Add Page</summary> | |
| 164 | + | <div style="margin-top: 0.75rem;"> | |
| 165 | + | <div class="form-group"> | |
| 166 | + | <label for="new-psec-title">Title</label> | |
| 167 | + | <input type="text" id="new-psec-title" placeholder="e.g. Privacy Policy, Terms..." autocomplete="off"> | |
| 168 | + | </div> | |
| 169 | + | <div class="form-group"> | |
| 170 | + | <label for="new-psec-body">Body (Markdown)</label> | |
| 171 | + | <textarea id="new-psec-body" rows="10" placeholder="Page content..."></textarea> | |
| 172 | + | </div> | |
| 173 | + | <button type="button" class="secondary" id="add-psec-btn" data-project-id="{{ project.id }}">Add Page</button> | |
| 174 | + | <span id="psec-add-status" style="margin-left: 0.5rem; font-size: 0.85rem;"></span> | |
| 175 | + | </div> | |
| 176 | + | </details> | |
| 177 | + | ||
| 178 | + | <div id="psection-edit-modal" style="display: none; margin-top: 1rem; padding: 1rem; background: var(--surface-muted);"> | |
| 179 | + | <input type="hidden" id="edit-psec-id"> | |
| 180 | + | <div class="form-group"> | |
| 181 | + | <label for="edit-psec-title">Title</label> | |
| 182 | + | <input type="text" id="edit-psec-title" autocomplete="off"> | |
| 183 | + | </div> | |
| 184 | + | <div class="form-group"> | |
| 185 | + | <label for="edit-psec-body">Body (Markdown)</label> | |
| 186 | + | <textarea id="edit-psec-body" rows="10"></textarea> | |
| 187 | + | </div> | |
| 188 | + | <div style="display: flex; gap: 0.5rem;"> | |
| 189 | + | <button type="button" class="primary" id="save-psec-btn" style="padding: 0.4rem 0.8rem;">Save</button> | |
| 190 | + | <button type="button" class="secondary" id="cancel-psec-btn" style="padding: 0.4rem 0.8rem;">Cancel</button> | |
| 191 | + | </div> | |
| 192 | + | <span id="psec-edit-status" style="margin-left: 0.5rem; font-size: 0.85rem;"></span> | |
| 193 | + | </div> | |
| 194 | + | </details> | |
| 195 | + | <script src="/static/project-sections.js" defer></script> | |
| 134 | 196 | <script> | |
| 135 | 197 | (function() { | |
| 136 | 198 | // Category suggestion dropdown |
| @@ -65,6 +65,7 @@ mod patches; | |||
| 65 | 65 | mod fingerprinting; | |
| 66 | 66 | mod video; | |
| 67 | 67 | mod item_sections; | |
| 68 | + | mod project_sections; | |
| 68 | 69 | mod imports; | |
| 69 | 70 | mod media_library; | |
| 70 | 71 | mod synckit_sse; |
| @@ -0,0 +1,365 @@ | |||
| 1 | + | //! Project sections: CRUD lifecycle, reorder, max limit, ownership, validation, public visibility. | |
| 2 | + | //! Mirrors item_sections tests but scoped to project-level markdown pages. | |
| 3 | + | ||
| 4 | + | use crate::harness::TestHarness; | |
| 5 | + | use serde_json::Value; | |
| 6 | + | ||
| 7 | + | /// Helper: create a creator with a project (no item needed), return project_id. | |
| 8 | + | async fn setup_creator_with_project(h: &mut TestHarness, username: &str) -> String { | |
| 9 | + | let setup = h.create_creator_with_item(username, "plugin", 0).await; | |
| 10 | + | setup.project_id | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | #[tokio::test] | |
| 14 | + | async fn project_section_create_update_delete() { | |
| 15 | + | let mut h = TestHarness::new().await; | |
| 16 | + | let project_id = setup_creator_with_project(&mut h, "psecrud").await; | |
| 17 | + | ||
| 18 | + | let resp = h | |
| 19 | + | .client | |
| 20 | + | .post_json( | |
| 21 | + | &format!("/api/projects/{}/sections", project_id), | |
| 22 | + | r#"{"title": "Privacy Policy", "body": "We don't collect data."}"#, | |
| 23 | + | ) | |
| 24 | + | .await; | |
| 25 | + | assert!(resp.status.is_success(), "Create failed: {} {}", resp.status, resp.text); | |
| 26 | + | let section: Value = resp.json(); | |
| 27 | + | let section_id = section["id"].as_str().unwrap().to_string(); | |
| 28 | + | assert_eq!(section["title"].as_str().unwrap(), "Privacy Policy"); | |
| 29 | + | assert_eq!(section["slug"].as_str().unwrap(), "privacy-policy"); | |
| 30 | + | assert_eq!(section["sort_order"].as_i64().unwrap(), 0); | |
| 31 | + | assert_eq!(section["project_id"].as_str().unwrap(), project_id); | |
| 32 | + | ||
| 33 | + | let resp = h | |
| 34 | + | .client | |
| 35 | + | .put_json( | |
| 36 | + | &format!("/api/project-sections/{}", section_id), | |
| 37 | + | r#"{"title": "Privacy & Terms", "body": "We still don't collect data."}"#, | |
| 38 | + | ) | |
| 39 | + | .await; | |
| 40 | + | assert!(resp.status.is_success(), "Update failed: {} {}", resp.status, resp.text); | |
| 41 | + | let updated: Value = resp.json(); | |
| 42 | + | assert_eq!(updated["title"].as_str().unwrap(), "Privacy & Terms"); | |
| 43 | + | assert_eq!(updated["slug"].as_str().unwrap(), "privacy-terms"); | |
| 44 | + | ||
| 45 | + | let resp = h | |
| 46 | + | .client | |
| 47 | + | .delete(&format!("/api/project-sections/{}", section_id)) | |
| 48 | + | .await; | |
| 49 | + | assert!(resp.status.is_success(), "Delete failed: {} {}", resp.status, resp.text); | |
| 50 | + | ||
| 51 | + | let count = sqlx::query_scalar::<_, i64>( | |
| 52 | + | "SELECT COUNT(*) FROM project_sections WHERE id = $1", | |
| 53 | + | ) | |
| 54 | + | .bind(section_id.parse::<uuid::Uuid>().unwrap()) | |
| 55 | + | .fetch_one(&h.db) | |
| 56 | + | .await | |
| 57 | + | .unwrap(); | |
| 58 | + | assert_eq!(count, 0, "Section should be deleted from database"); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | #[tokio::test] | |
| 62 | + | async fn project_section_list_public_only() { | |
| 63 | + | let mut h = TestHarness::new().await; | |
| 64 | + | let project_id = setup_creator_with_project(&mut h, "pseclist").await; | |
| 65 | + | ||
| 66 | + | let resp = h | |
| 67 | + | .client | |
| 68 | + | .post_json( | |
| 69 | + | &format!("/api/projects/{}/sections", project_id), | |
| 70 | + | r#"{"title": "FAQ", "body": "Q: ...?"}"#, | |
| 71 | + | ) | |
| 72 | + | .await; | |
| 73 | + | assert!(resp.status.is_success()); | |
| 74 | + | ||
| 75 | + | // Make project private (projects default to is_public=true). | |
| 76 | + | h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": false}"#).await; | |
| 77 | + | h.client.post_form("/logout", "").await; | |
| 78 | + | h.client.fetch_csrf_token().await; | |
| 79 | + | let resp = h.client.get(&format!("/api/projects/{}/sections", project_id)).await; | |
| 80 | + | assert_eq!(resp.status, 404, "Private project sections should return 404"); | |
| 81 | + | ||
| 82 | + | // Publish project | |
| 83 | + | h.login("pseclist", "password123").await; | |
| 84 | + | h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await; | |
| 85 | + | ||
| 86 | + | h.client.post_form("/logout", "").await; | |
| 87 | + | h.client.fetch_csrf_token().await; | |
| 88 | + | let resp = h.client.get(&format!("/api/projects/{}/sections", project_id)).await; | |
| 89 | + | assert!(resp.status.is_success(), "Public list failed: {} {}", resp.status, resp.text); | |
| 90 | + | let list: Value = resp.json(); | |
| 91 | + | let data = list["data"].as_array().unwrap(); | |
| 92 | + | assert_eq!(data.len(), 1); | |
| 93 | + | assert_eq!(data[0]["title"].as_str().unwrap(), "FAQ"); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | #[tokio::test] | |
| 97 | + | async fn project_section_reorder() { | |
| 98 | + | let mut h = TestHarness::new().await; | |
| 99 | + | let project_id = setup_creator_with_project(&mut h, "psecreorder").await; | |
| 100 | + | ||
| 101 | + | let mut ids = Vec::new(); | |
| 102 | + | for title in &["Alpha", "Beta", "Gamma"] { | |
| 103 | + | let body = format!(r#"{{"title": "{}", "body": ""}}"#, title); | |
| 104 | + | let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; | |
| 105 | + | assert!(resp.status.is_success()); | |
| 106 | + | let sec: Value = resp.json(); | |
| 107 | + | ids.push(sec["id"].as_str().unwrap().to_string()); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | let reorder_body = format!( | |
| 111 | + | r#"{{"section_ids": ["{}", "{}", "{}"]}}"#, | |
| 112 | + | ids[2], ids[0], ids[1] | |
| 113 | + | ); | |
| 114 | + | let resp = h | |
| 115 | + | .client | |
| 116 | + | .put_json( | |
| 117 | + | &format!("/api/projects/{}/sections/reorder", project_id), | |
| 118 | + | &reorder_body, | |
| 119 | + | ) | |
| 120 | + | .await; | |
| 121 | + | assert!(resp.status.is_success(), "Reorder failed: {} {}", resp.status, resp.text); | |
| 122 | + | ||
| 123 | + | let rows = sqlx::query_as::<_, (String, i32)>( | |
| 124 | + | "SELECT title, sort_order FROM project_sections WHERE project_id = $1 ORDER BY sort_order", | |
| 125 | + | ) | |
| 126 | + | .bind(project_id.parse::<uuid::Uuid>().unwrap()) | |
| 127 | + | .fetch_all(&h.db) | |
| 128 | + | .await | |
| 129 | + | .unwrap(); | |
| 130 | + | assert_eq!(rows[0].0, "Gamma"); | |
| 131 | + | assert_eq!(rows[1].0, "Alpha"); | |
| 132 | + | assert_eq!(rows[2].0, "Beta"); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | #[tokio::test] | |
| 136 | + | async fn project_section_max_limit() { | |
| 137 | + | let mut h = TestHarness::new().await; | |
| 138 | + | let project_id = setup_creator_with_project(&mut h, "psecmax").await; | |
| 139 | + | ||
| 140 | + | for i in 0..10 { | |
| 141 | + | let body = format!(r#"{{"title": "Page {}", "body": ""}}"#, i); | |
| 142 | + | let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; | |
| 143 | + | assert!( | |
| 144 | + | resp.status.is_success(), | |
| 145 | + | "Page {} create failed: {} {}", | |
| 146 | + | i, resp.status, resp.text | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | let resp = h | |
| 151 | + | .client | |
| 152 | + | .post_json( | |
| 153 | + | &format!("/api/projects/{}/sections", project_id), | |
| 154 | + | r#"{"title": "Too Many", "body": ""}"#, | |
| 155 | + | ) | |
| 156 | + | .await; | |
| 157 | + | assert!( | |
| 158 | + | resp.status == 400 || resp.status == 422, | |
| 159 | + | "11th section should be rejected, got {} {}", | |
| 160 | + | resp.status, resp.text | |
| 161 | + | ); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | #[tokio::test] | |
| 165 | + | async fn project_section_ownership_enforced() { | |
| 166 | + | let mut h = TestHarness::new().await; | |
| 167 | + | let project_id = setup_creator_with_project(&mut h, "psecowner").await; | |
| 168 | + | ||
| 169 | + | let resp = h | |
| 170 | + | .client | |
| 171 | + | .post_json( | |
| 172 | + | &format!("/api/projects/{}/sections", project_id), | |
| 173 | + | r#"{"title": "Private", "body": "secret"}"#, | |
| 174 | + | ) | |
| 175 | + | .await; | |
| 176 | + | assert!(resp.status.is_success()); | |
| 177 | + | let section: Value = resp.json(); | |
| 178 | + | let section_id = section["id"].as_str().unwrap().to_string(); | |
| 179 | + | ||
| 180 | + | h.client.post_form("/logout", "").await; | |
| 181 | + | let b_id = h.signup("psecintruder", "psecintruder@test.com", "password123").await; | |
| 182 | + | h.grant_creator(b_id).await; | |
| 183 | + | h.client.post_form("/logout", "").await; | |
| 184 | + | h.login("psecintruder", "password123").await; | |
| 185 | + | ||
| 186 | + | let resp = h | |
| 187 | + | .client | |
| 188 | + | .put_json( | |
| 189 | + | &format!("/api/project-sections/{}", section_id), | |
| 190 | + | r#"{"title": "Hacked", "body": "pwned"}"#, | |
| 191 | + | ) | |
| 192 | + | .await; | |
| 193 | + | assert_eq!(resp.status, 403, "Non-owner PUT should be 403, got {} {}", resp.status, resp.text); | |
| 194 | + | ||
| 195 | + | let resp = h.client.delete(&format!("/api/project-sections/{}", section_id)).await; | |
| 196 | + | assert_eq!(resp.status, 403, "Non-owner DELETE should be 403, got {} {}", resp.status, resp.text); | |
| 197 | + | ||
| 198 | + | let resp = h | |
| 199 | + | .client | |
| 200 | + | .post_json( | |
| 201 | + | &format!("/api/projects/{}/sections", project_id), | |
| 202 | + | r#"{"title": "Inject", "body": ""}"#, | |
| 203 | + | ) | |
| 204 | + | .await; | |
| 205 | + | assert_eq!(resp.status, 403, "Non-owner POST should be 403, got {} {}", resp.status, resp.text); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[tokio::test] | |
| 209 | + | async fn project_section_title_validation() { | |
| 210 | + | let mut h = TestHarness::new().await; | |
| 211 | + | let project_id = setup_creator_with_project(&mut h, "psecvalid").await; | |
| 212 | + | ||
| 213 | + | let resp = h | |
| 214 | + | .client | |
| 215 | + | .post_json( | |
| 216 | + | &format!("/api/projects/{}/sections", project_id), | |
| 217 | + | r#"{"title": "", "body": ""}"#, | |
| 218 | + | ) | |
| 219 | + | .await; | |
| 220 | + | assert!( | |
| 221 | + | resp.status == 400 || resp.status == 422, | |
| 222 | + | "Empty title rejected, got {} {}", | |
| 223 | + | resp.status, resp.text | |
| 224 | + | ); | |
| 225 | + | ||
| 226 | + | let resp = h | |
| 227 | + | .client | |
| 228 | + | .post_json( | |
| 229 | + | &format!("/api/projects/{}/sections", project_id), | |
| 230 | + | r#"{"title": " ", "body": ""}"#, | |
| 231 | + | ) | |
| 232 | + | .await; | |
| 233 | + | assert!( | |
| 234 | + | resp.status == 400 || resp.status == 422, | |
| 235 | + | "Whitespace title rejected, got {} {}", | |
| 236 | + | resp.status, resp.text | |
| 237 | + | ); | |
| 238 | + | ||
| 239 | + | let long_title = "A".repeat(101); | |
| 240 | + | let body = format!(r#"{{"title": "{}", "body": ""}}"#, long_title); | |
| 241 | + | let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; | |
| 242 | + | assert!( | |
| 243 | + | resp.status == 400 || resp.status == 422, | |
| 244 | + | "101-char title rejected, got {} {}", | |
| 245 | + | resp.status, resp.text | |
| 246 | + | ); | |
| 247 | + | ||
| 248 | + | let title_100 = "A".repeat(100); | |
| 249 | + | let body = format!(r#"{{"title": "{}", "body": ""}}"#, title_100); | |
| 250 | + | let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await; | |
| 251 | + | assert!( | |
| 252 | + | resp.status.is_success(), | |
| 253 | + | "100-char title accepted, got {} {}", | |
| 254 | + | resp.status, resp.text | |
| 255 | + | ); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[tokio::test] | |
| 259 | + | async fn project_section_unauthenticated_rejected() { | |
| 260 | + | let mut h = TestHarness::new().await; | |
| 261 | + | let project_id = setup_creator_with_project(&mut h, "psecunauth").await; | |
| 262 | + | ||
| 263 | + | let resp = h | |
| 264 | + | .client | |
| 265 | + | .post_json( | |
| 266 | + | &format!("/api/projects/{}/sections", project_id), | |
| 267 | + | r#"{"title": "Temp", "body": ""}"#, | |
| 268 | + | ) | |
| 269 | + | .await; | |
| 270 | + | assert!(resp.status.is_success()); | |
| 271 | + | let section: Value = resp.json(); | |
| 272 | + | let section_id = section["id"].as_str().unwrap().to_string(); | |
| 273 | + | ||
| 274 | + | h.client.post_form("/logout", "").await; | |
| 275 | + | h.client.fetch_csrf_token().await; | |
| 276 | + | ||
| 277 | + | let resp = h | |
| 278 | + | .client | |
| 279 | + | .post_json( | |
| 280 | + | &format!("/api/projects/{}/sections", project_id), | |
| 281 | + | r#"{"title": "No Auth", "body": ""}"#, | |
| 282 | + | ) | |
| 283 | + | .await; | |
| 284 | + | assert_eq!(resp.status, 401, "Unauth POST: 401, got {} {}", resp.status, resp.text); | |
| 285 | + | ||
| 286 | + | let resp = h | |
| 287 | + | .client | |
| 288 | + | .put_json( | |
| 289 | + | &format!("/api/project-sections/{}", section_id), | |
| 290 | + | r#"{"title": "No Auth", "body": ""}"#, | |
| 291 | + | ) | |
| 292 | + | .await; | |
| 293 | + | assert_eq!(resp.status, 401, "Unauth PUT: 401, got {} {}", resp.status, resp.text); | |
| 294 | + | ||
| 295 | + | let resp = h.client.delete(&format!("/api/project-sections/{}", section_id)).await; | |
| 296 | + | assert_eq!(resp.status, 401, "Unauth DELETE: 401, got {} {}", resp.status, resp.text); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | #[tokio::test] | |
| 300 | + | async fn project_section_nonexistent_project() { | |
| 301 | + | let mut h = TestHarness::new().await; | |
| 302 | + | let user_id = h.signup("psecghost", "psecghost@test.com", "password123").await; | |
| 303 | + | h.grant_creator(user_id).await; | |
| 304 | + | h.client.post_form("/logout", "").await; | |
| 305 | + | h.login("psecghost", "password123").await; | |
| 306 | + | ||
| 307 | + | let fake_id = uuid::Uuid::new_v4(); | |
| 308 | + | let resp = h | |
| 309 | + | .client | |
| 310 | + | .post_json( | |
| 311 | + | &format!("/api/projects/{}/sections", fake_id), | |
| 312 | + | r#"{"title": "Ghost", "body": ""}"#, | |
| 313 | + | ) | |
| 314 | + | .await; | |
| 315 | + | assert_eq!(resp.status, 404, "Nonexistent project: 404, got {} {}", resp.status, resp.text); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[tokio::test] | |
| 319 | + | async fn project_section_slug_generation() { | |
| 320 | + | let mut h = TestHarness::new().await; | |
| 321 | + | let project_id = setup_creator_with_project(&mut h, "psecslug").await; | |
| 322 | + | ||
| 323 | + | let resp = h | |
| 324 | + | .client | |
| 325 | + | .post_json( | |
| 326 | + | &format!("/api/projects/{}/sections", project_id), | |
| 327 | + | r#"{"title": "Terms of Service!", "body": ""}"#, | |
| 328 | + | ) | |
| 329 | + | .await; | |
| 330 | + | assert!(resp.status.is_success()); | |
| 331 | + | let section: Value = resp.json(); | |
| 332 | + | let slug = section["slug"].as_str().unwrap(); | |
| 333 | + | assert!(slug.contains("terms"), "Slug should contain 'terms', got '{}'", slug); | |
| 334 | + | assert!(!slug.contains('!'), "Slug should not contain '!', got '{}'", slug); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[tokio::test] | |
| 338 | + | async fn project_section_unique_slug_per_project() { | |
| 339 | + | // Two different projects can have sections with the same slug; | |
| 340 | + | // within one project, duplicates fail at the DB unique index. | |
| 341 | + | let mut h = TestHarness::new().await; | |
| 342 | + | let project_id = setup_creator_with_project(&mut h, "psecuniq").await; | |
| 343 | + | ||
| 344 | + | let resp = h | |
| 345 | + | .client | |
| 346 | + | .post_json( | |
| 347 | + | &format!("/api/projects/{}/sections", project_id), | |
| 348 | + | r#"{"title": "Privacy Policy", "body": ""}"#, | |
| 349 | + | ) | |
| 350 | + | .await; | |
| 351 | + | assert!(resp.status.is_success()); | |
| 352 | + | ||
| 353 | + | let resp = h | |
| 354 | + | .client | |
| 355 | + | .post_json( | |
| 356 | + | &format!("/api/projects/{}/sections", project_id), | |
| 357 | + | r#"{"title": "Privacy Policy", "body": ""}"#, | |
| 358 | + | ) | |
| 359 | + | .await; | |
| 360 | + | assert!( | |
| 361 | + | !resp.status.is_success(), | |
| 362 | + | "Duplicate slug within a project should fail, got {} {}", | |
| 363 | + | resp.status, resp.text | |
| 364 | + | ); | |
| 365 | + | } |