Skip to main content

max / makenotwork

v0.5.17: Add project sections (tabbed markdown pages) Mirrors item_sections for projects: privacy policy, terms, FAQ, etc. appear as tabs on public project pages with #section-<slug> deep-linking. - Migration 113: project_sections table (max 10 per project) - API: POST/GET /api/projects/{id}/sections, PUT/DELETE /api/project-sections/{id}, PUT /api/projects/{id}/sections/reorder - Public project page: tab UI + hash-based deep linking - Dashboard: "Pages" editor in project Settings tab - Tests: 10 integration tests covering CRUD, ownership, validation, public visibility, slug generation, unique constraint Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-13 02:00 UTC
Commit: 14037c340cebea2aae1a32963ea3096035a28a5e
Parent: 510fd4d
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-&lt;slug&gt;</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 + }