Skip to content

Commit d427cfb

Browse files
committed
feat(graph): implement create portal target project functionality
1 parent 3d292ea commit d427cfb

File tree

19 files changed

+813
-128
lines changed

19 files changed

+813
-128
lines changed

public/css/app.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2112,6 +2112,7 @@ Scale: 0, px, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16,
21122112
.rounded-l-lg { border-top-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem; }
21132113
.rounded-l-xl { border-top-left-radius: 0.75rem; border-bottom-left-radius: 0.75rem; }
21142114
.rounded-l-full { border-top-left-radius: 9999px; border-bottom-left-radius: 9999px; }
2115+
.rounded-tl-none { border-top-left-radius: 0; }
21152116
/* ==========================================================================
21162117
6. BORDERS — Divide
21172118
========================================================================== */

src/app/db/projects.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,15 @@ where
179179
}
180180

181181
/// Find a project by ID.
182-
pub async fn find_by_id(pool: &sqlx::SqlitePool, id: &str) -> Result<Option<Project>, sqlx::Error> {
182+
pub async fn find_by_id<'e, E>(executor: E, id: &str) -> Result<Option<Project>, sqlx::Error>
183+
where
184+
E: sqlx::Executor<'e, Database = sqlx::Sqlite>,
185+
{
183186
sqlx::query_as::<_, Project>(
184187
"SELECT id, title, user_id, created_at, COALESCE(updated_at, created_at) AS updated_at, organization_id, team_id, default_view_mode, due_utc, due_timezone FROM projects WHERE id = ?",
185188
)
186189
.bind(id)
187-
.fetch_optional(pool)
190+
.fetch_optional(executor)
188191
.await
189192
}
190193

src/app/features/graph/api.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub fn routes() -> Router<AppState> {
1515
.merge(crate::app::features::graph::create_node::routes())
1616
.merge(crate::app::features::graph::update_node::routes())
1717
.merge(crate::app::features::graph::put_node_portal::routes())
18+
.merge(crate::app::features::graph::create_portal_target_project::routes())
1819
.merge(crate::app::features::graph::delete_node::routes())
1920
.merge(crate::app::features::graph::create_edge::routes())
2021
.merge(crate::app::features::graph::delete_edge::routes())
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//! POST /api/projects/:project_id/nodes/:id/portal-target-project — create an org project and set this task’s portal in one transaction.
2+
3+
use axum::{
4+
extract::{Path, State},
5+
http::StatusCode,
6+
routing::post,
7+
Json, Router,
8+
};
9+
use serde::Deserialize;
10+
11+
use crate::app::{
12+
db,
13+
error::AppError,
14+
session::ApiAuthenticatedSession,
15+
AppState,
16+
};
17+
18+
use super::types::ApiNode;
19+
20+
#[derive(Debug, Deserialize)]
21+
pub struct CreatePortalTargetProjectRequest {
22+
pub title: String,
23+
pub team_id: String,
24+
}
25+
26+
/// POST /api/projects/:project_id/nodes/:id/portal-target-project
27+
pub async fn post_create_portal_target_project(
28+
ApiAuthenticatedSession(session): ApiAuthenticatedSession,
29+
State(state): State<AppState>,
30+
Path(params): Path<super::types::NodePathParams>,
31+
Json(body): Json<CreatePortalTargetProjectRequest>,
32+
) -> Result<(StatusCode, Json<ApiNode>), AppError> {
33+
let title = body.title.trim();
34+
if title.is_empty() || title.len() > 255 {
35+
return Err(AppError::Validation(
36+
"Title must be 1–255 characters".to_string(),
37+
));
38+
}
39+
if body.team_id.is_empty() {
40+
return Err(AppError::Validation(
41+
"team_id must not be empty".to_string(),
42+
));
43+
}
44+
45+
let _project =
46+
super::helpers::ensure_project_accessible(&state.db, &params.project_id, &session.user_id)
47+
.await?;
48+
49+
let node = db::nodes::find_by_id(&state.db, &params.id)
50+
.await?
51+
.ok_or_else(|| AppError::NotFound("Node not found".to_string()))?;
52+
53+
if node.project_id != params.project_id {
54+
return Err(AppError::NotFound("Node not found".to_string()));
55+
}
56+
57+
let org_id = node.organization_id.as_deref().ok_or_else(|| AppError::Internal)?;
58+
59+
let team = db::teams::find_by_id(&state.db, &body.team_id)
60+
.await?
61+
.ok_or_else(|| AppError::Validation("Invalid team".to_string()))?;
62+
63+
if team.organization_id != org_id {
64+
return Err(AppError::Validation("Invalid team".to_string()));
65+
}
66+
67+
let new_id = ulid::Ulid::new().to_string();
68+
let new_project = db::projects::NewProject {
69+
id: new_id.clone(),
70+
title: title.to_string(),
71+
user_id: session.user_id.clone(),
72+
organization_id: org_id.to_string(),
73+
team_id: team.id,
74+
due_utc: None,
75+
due_timezone: None,
76+
};
77+
78+
let mut tx = state.db.begin().await?;
79+
db::projects::insert(&mut *tx, &new_project).await?;
80+
81+
super::helpers::validate_portal_target_for_node(
82+
&state.db,
83+
&mut tx,
84+
&node,
85+
&new_id,
86+
&session.user_id,
87+
)
88+
.await?;
89+
90+
db::node_portal_links::upsert(&mut *tx, &node.id, &new_id)
91+
.await
92+
.map_err(super::helpers::map_node_portal_upsert_error)?;
93+
94+
tx.commit().await?;
95+
96+
let updated = db::nodes::find_by_id(&state.db, &node.id)
97+
.await?
98+
.ok_or_else(|| AppError::Internal)?;
99+
100+
let response = super::helpers::api_node_with_portal(&state.db, &updated).await?;
101+
102+
Ok((StatusCode::CREATED, Json(response)))
103+
}
104+
105+
pub fn routes() -> Router<AppState> {
106+
Router::new().route(
107+
"/api/projects/:project_id/nodes/:id/portal-target-project",
108+
post(post_create_portal_target_project),
109+
)
110+
}

src/app/features/graph/helpers.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,87 @@ pub async fn ensure_project_accessible(
8484
Ok(project)
8585
}
8686

87+
/// User-facing message when a second portal would use the same target project (org-wide).
88+
pub const PORTAL_TARGET_ALREADY_IN_USE: &str =
89+
"Another task already links to that target project";
90+
91+
pub fn map_node_portal_upsert_error(err: sqlx::Error) -> AppError {
92+
if let sqlx::Error::Database(ref db) = err {
93+
let msg = db.message();
94+
if msg.contains("UNIQUE constraint failed") && msg.contains("target_project_id") {
95+
return AppError::Validation(PORTAL_TARGET_ALREADY_IN_USE.to_string());
96+
}
97+
}
98+
AppError::Database(err)
99+
}
100+
101+
/// Self-link, org match, target not already used by another task, no cycle — for an existing target project.
102+
///
103+
/// All reads that must see uncommitted work (e.g. a project row inserted in the same transaction) use `tx`.
104+
pub async fn validate_portal_target_for_node(
105+
pool: &sqlx::SqlitePool,
106+
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
107+
node: &db::nodes::Node,
108+
target_project_id: &str,
109+
user_id: &str,
110+
) -> Result<(), AppError> {
111+
if target_project_id == node.project_id.as_str() {
112+
return Err(AppError::Validation(
113+
"Cannot link a portal to the same project".to_string(),
114+
));
115+
}
116+
117+
let target_project = db::projects::find_by_id(tx.as_mut(), target_project_id)
118+
.await?
119+
.ok_or_else(|| AppError::NotFound("Project not found".to_string()))?;
120+
121+
let org_id = node.organization_id.as_deref().ok_or_else(|| AppError::Internal)?;
122+
123+
if target_project.organization_id != org_id {
124+
return Err(AppError::Validation(
125+
"Portal target must be in the same organization".to_string(),
126+
));
127+
}
128+
129+
tenant::require_org_member(pool, user_id, &target_project.organization_id)
130+
.await
131+
.map_err(|e| match &e {
132+
AppError::NotFound(_) => AppError::NotFound("Project not found".to_string()),
133+
_ => e,
134+
})?;
135+
136+
if db::node_portal_links::portal_target_in_use(
137+
tx.as_mut(),
138+
target_project_id,
139+
Some(node.id.as_str()),
140+
)
141+
.await?
142+
{
143+
return Err(AppError::Validation(
144+
PORTAL_TARGET_ALREADY_IN_USE.to_string(),
145+
));
146+
}
147+
148+
let edges = db::node_portal_links::list_portal_project_edges_for_org(
149+
tx.as_mut(),
150+
org_id,
151+
Some(node.id.as_str()),
152+
)
153+
.await?;
154+
155+
if db::node_portal_links::portal_link_would_cycle(
156+
&edges,
157+
node.project_id.as_str(),
158+
target_project_id,
159+
) {
160+
return Err(AppError::Validation(
161+
"Portal link would create a cycle between projects".to_string(),
162+
));
163+
}
164+
165+
Ok(())
166+
}
167+
87168
/// Validates URL project access, loads both endpoints, ensures at least one node belongs to the URL
88169
/// project and both projects share the same organization.
89170
pub async fn load_edge_endpoints_for_write(

src/app/features/graph/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod api;
22
pub mod create_edge;
33
pub mod create_node;
4+
pub mod create_portal_target_project;
45
mod defaults;
56
pub mod delete_edge;
67
pub mod delete_node;

src/app/features/graph/put_node_portal.rs

Lines changed: 9 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,6 @@ use crate::app::{
1717

1818
use super::types::ApiNode;
1919

20-
/// User-facing message when a second portal would use the same target project (org-wide).
21-
const PORTAL_TARGET_ALREADY_IN_USE: &str =
22-
"Another task already links to that target project";
23-
24-
fn map_node_portal_upsert_error(err: sqlx::Error) -> AppError {
25-
if let sqlx::Error::Database(ref db) = err {
26-
let msg = db.message();
27-
if msg.contains("UNIQUE constraint failed") && msg.contains("target_project_id") {
28-
return AppError::Validation(PORTAL_TARGET_ALREADY_IN_USE.to_string());
29-
}
30-
}
31-
AppError::Database(err)
32-
}
33-
3420
#[derive(Debug, Deserialize)]
3521
pub struct PutNodePortalRequest {
3622
/// `null` clears the portal; a project id sets the target (same org as the node).
@@ -64,69 +50,25 @@ pub async fn put_node_portal(
6450
return Err(AppError::NotFound("Node not found".to_string()));
6551
}
6652

67-
let org_id = node.organization_id.as_deref().ok_or_else(|| AppError::Internal)?;
68-
6953
match &body.target_project_id {
7054
None => {
7155
db::node_portal_links::delete_for_node(&state.db, &node.id).await?;
7256
}
7357
Some(target_id) => {
74-
if target_id == &node.project_id {
75-
return Err(AppError::Validation(
76-
"Cannot link a portal to the same project".to_string(),
77-
));
78-
}
79-
80-
let target_project = db::projects::find_by_id(&state.db, target_id)
81-
.await?
82-
.ok_or_else(|| AppError::NotFound("Project not found".to_string()))?;
83-
84-
if target_project.organization_id != org_id {
85-
return Err(AppError::Validation(
86-
"Portal target must be in the same organization".to_string(),
87-
));
88-
}
89-
90-
crate::app::tenant::require_org_member(
58+
let mut tx = state.db.begin().await?;
59+
super::helpers::validate_portal_target_for_node(
9160
&state.db,
61+
&mut tx,
62+
&node,
63+
target_id.as_str(),
9264
&session.user_id,
93-
&target_project.organization_id,
94-
)
95-
.await
96-
.map_err(|e| match &e {
97-
AppError::NotFound(_) => AppError::NotFound("Project not found".to_string()),
98-
_ => e,
99-
})?;
100-
101-
if db::node_portal_links::portal_target_in_use(
102-
&state.db,
103-
target_id,
104-
Some(node.id.as_str()),
10565
)
106-
.await?
107-
{
108-
return Err(AppError::Validation(
109-
PORTAL_TARGET_ALREADY_IN_USE.to_string(),
110-
));
111-
}
112-
113-
let edges =
114-
db::node_portal_links::list_portal_project_edges_for_org(&state.db, org_id, Some(&node.id))
115-
.await?;
116-
117-
if db::node_portal_links::portal_link_would_cycle(
118-
&edges,
119-
node.project_id.as_str(),
120-
target_id.as_str(),
121-
) {
122-
return Err(AppError::Validation(
123-
"Portal link would create a cycle between projects".to_string(),
124-
));
125-
}
66+
.await?;
12667

127-
db::node_portal_links::upsert(&state.db, &node.id, target_id)
68+
db::node_portal_links::upsert(&mut *tx, &node.id, target_id)
12869
.await
129-
.map_err(map_node_portal_upsert_error)?;
70+
.map_err(super::helpers::map_node_portal_upsert_error)?;
71+
tx.commit().await?;
13072
}
13173
}
13274

src/app/features/projects/helpers.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,40 @@ pub fn portal_picker_projects_json(
110110
serde_json::to_string(&rows).unwrap_or_else(|_| "[]".to_string())
111111
}
112112

113+
/// JSON object `{ "teams": [{ "id", "name" }], "default_team_id" }` for the portal “create project & link” UI.
114+
pub fn portal_team_options_json(
115+
teams: Vec<db::teams::Team>,
116+
project_team_id: Option<&str>,
117+
) -> String {
118+
#[derive(serde::Serialize)]
119+
struct TeamRow {
120+
id: String,
121+
name: String,
122+
}
123+
#[derive(serde::Serialize)]
124+
struct Payload {
125+
teams: Vec<TeamRow>,
126+
default_team_id: String,
127+
}
128+
let team_rows: Vec<TeamRow> = teams
129+
.iter()
130+
.map(|t| TeamRow {
131+
id: t.id.clone(),
132+
name: t.name.clone(),
133+
})
134+
.collect();
135+
let default_team_id = project_team_id
136+
.filter(|tid| teams.iter().any(|t| t.id.as_str() == *tid))
137+
.map(|s| s.to_string())
138+
.or_else(|| teams.first().map(|t| t.id.clone()))
139+
.unwrap_or_default();
140+
serde_json::to_string(&Payload {
141+
teams: team_rows,
142+
default_team_id,
143+
})
144+
.unwrap_or_else(|_| r#"{"teams":[],"default_team_id":""}"#.to_string())
145+
}
146+
113147
/// Load project by id. Returns the project or an (status, message) for HTML error responses.
114148
/// Caller is responsible for auth (e.g. tenant::require_org_member).
115149
pub async fn load_project(

0 commit comments

Comments
 (0)