Skip to main content

max / makenotwork

3.7 KB · 143 lines History Blame Raw
1 //! Project section CRUD: tabbed markdown content blocks within projects.
2
3 use sqlx::PgPool;
4
5 use super::models::DbProjectSection;
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(crate) async fn list_by_project(
12 pool: &PgPool,
13 project_id: ProjectId,
14 ) -> Result<Vec<DbProjectSection>> {
15 let sections = sqlx::query_as::<_, DbProjectSection>(
16 "SELECT * FROM project_sections WHERE project_id = $1 ORDER BY sort_order LIMIT 500",
17 )
18 .bind(project_id)
19 .fetch_all(pool)
20 .await?;
21
22 Ok(sections)
23 }
24
25 /// Fetch a section by primary key. Returns `None` if not found.
26 #[tracing::instrument(skip_all)]
27 pub(crate) async fn get_by_id(
28 pool: &PgPool,
29 section_id: ProjectSectionId,
30 ) -> Result<Option<DbProjectSection>> {
31 let section =
32 sqlx::query_as::<_, DbProjectSection>("SELECT * FROM project_sections WHERE id = $1")
33 .bind(section_id)
34 .fetch_optional(pool)
35 .await?;
36
37 Ok(section)
38 }
39
40 /// Insert a new section for a project.
41 #[tracing::instrument(skip_all)]
42 pub(crate) async fn create(
43 pool: &PgPool,
44 project_id: ProjectId,
45 title: &str,
46 slug: &str,
47 body: &str,
48 sort_order: i32,
49 ) -> Result<DbProjectSection> {
50 let section = sqlx::query_as::<_, DbProjectSection>(
51 r"
52 INSERT INTO project_sections (project_id, title, slug, body, sort_order)
53 VALUES ($1, $2, $3, $4, $5)
54 RETURNING *
55 ",
56 )
57 .bind(project_id)
58 .bind(title)
59 .bind(slug)
60 .bind(body)
61 .bind(sort_order)
62 .fetch_one(pool)
63 .await?;
64
65 Ok(section)
66 }
67
68 /// Update a section's title, slug, and body.
69 #[tracing::instrument(skip_all)]
70 pub(crate) async fn update(
71 pool: &PgPool,
72 section_id: ProjectSectionId,
73 title: &str,
74 slug: &str,
75 body: &str,
76 ) -> Result<DbProjectSection> {
77 let section = sqlx::query_as::<_, DbProjectSection>(
78 r"
79 UPDATE project_sections
80 SET title = $2, slug = $3, body = $4, updated_at = now()
81 WHERE id = $1
82 RETURNING *
83 ",
84 )
85 .bind(section_id)
86 .bind(title)
87 .bind(slug)
88 .bind(body)
89 .fetch_one(pool)
90 .await?;
91
92 Ok(section)
93 }
94
95 /// Permanently delete a section by ID.
96 #[tracing::instrument(skip_all)]
97 pub(crate) async fn delete(pool: &PgPool, section_id: ProjectSectionId) -> Result<()> {
98 sqlx::query("DELETE FROM project_sections WHERE id = $1")
99 .bind(section_id)
100 .execute(pool)
101 .await?;
102
103 Ok(())
104 }
105
106 /// Reorder sections by setting sort_order from an ordered list of IDs.
107 ///
108 /// A single `UNNEST ... WITH ORDINALITY` update: atomic (no partial ordering on
109 /// failure) and one round-trip instead of one query per section.
110 #[tracing::instrument(skip_all)]
111 pub(crate) async fn reorder(
112 pool: &PgPool,
113 project_id: ProjectId,
114 section_ids: &[ProjectSectionId],
115 ) -> Result<()> {
116 let ids: Vec<uuid::Uuid> = section_ids.iter().map(|id| *id.as_uuid()).collect();
117 sqlx::query(
118 r"
119 UPDATE project_sections AS s
120 SET sort_order = ord.pos::int - 1, updated_at = now()
121 FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos)
122 WHERE s.id = ord.id AND s.project_id = $2
123 ",
124 )
125 .bind(&ids)
126 .bind(project_id)
127 .execute(pool)
128 .await?;
129
130 Ok(())
131 }
132
133 /// Count sections for a project.
134 #[tracing::instrument(skip_all)]
135 pub(crate) async fn count_by_project(pool: &PgPool, project_id: ProjectId) -> Result<i64> {
136 let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM project_sections WHERE project_id = $1")
137 .bind(project_id)
138 .fetch_one(pool)
139 .await?;
140
141 Ok(row.0)
142 }
143