feat: Implement full-stack cohort management with dedicated API, database schema, and admin UI, alongside updates to the database reset script and documentation.

This commit is contained in:
2026-02-16 04:03:19 -03:00
parent fbac6b4405
commit 172b4fa2d5
10 changed files with 550 additions and 3 deletions
@@ -0,0 +1,146 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::auth::Claims;
use common::middleware::Org;
use common::models::{AddMemberPayload, Cohort, CreateCohortPayload, UserCohort};
use sqlx::PgPool;
use uuid::Uuid;
pub async fn list_cohorts(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Cohort>>, (StatusCode, String)> {
let cohorts = sqlx::query_as::<_, Cohort>(
"SELECT * FROM cohorts WHERE organization_id = $1 ORDER BY created_at DESC",
)
.bind(org_ctx.id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(cohorts))
}
pub async fn create_cohort(
Org(org_ctx): Org,
_claims: Claims,
State(pool): State<PgPool>,
Json(payload): Json<CreateCohortPayload>,
) -> Result<Json<Cohort>, (StatusCode, String)> {
let cohort = sqlx::query_as::<_, Cohort>(
r#"
INSERT INTO cohorts (organization_id, name, description)
VALUES ($1, $2, $3)
RETURNING *
"#,
)
.bind(org_ctx.id)
.bind(payload.name)
.bind(payload.description)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(cohort))
}
pub async fn add_cohort_member(
Org(org_ctx): Org,
_claims: Claims,
Path(cohort_id): Path<Uuid>,
State(pool): State<PgPool>,
Json(payload): Json<AddMemberPayload>,
) -> Result<Json<UserCohort>, (StatusCode, String)> {
// Verify cohort belongs to org
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
)
.bind(cohort_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
}
let member = sqlx::query_as::<_, UserCohort>(
r#"
INSERT INTO user_cohorts (cohort_id, user_id)
VALUES ($1, $2)
ON CONFLICT (cohort_id, user_id) DO UPDATE SET assigned_at = NOW()
RETURNING *
"#,
)
.bind(cohort_id)
.bind(payload.user_id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(member))
}
pub async fn remove_cohort_member(
Org(org_ctx): Org,
_claims: Claims,
Path((cohort_id, user_id)): Path<(Uuid, Uuid)>,
State(pool): State<PgPool>,
) -> Result<StatusCode, (StatusCode, String)> {
// Verify cohort belongs to org
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
)
.bind(cohort_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
}
sqlx::query("DELETE FROM user_cohorts WHERE cohort_id = $1 AND user_id = $2")
.bind(cohort_id)
.bind(user_id)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_cohort_members(
Org(org_ctx): Org,
_claims: Claims,
Path(cohort_id): Path<Uuid>,
State(pool): State<PgPool>,
) -> Result<Json<Vec<Uuid>>, (StatusCode, String)> {
// Verify cohort belongs to org
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM cohorts WHERE id = $1 AND organization_id = $2)",
)
.bind(cohort_id)
.bind(org_ctx.id)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if !exists {
return Err((StatusCode::NOT_FOUND, "Cohort not found".to_string()));
}
let members = sqlx::query_scalar("SELECT user_id FROM user_cohorts WHERE cohort_id = $1")
.bind(cohort_id)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(members))
}
+16
View File
@@ -1,6 +1,7 @@
mod db_util;
mod handlers;
mod handlers_announcements;
mod handlers_cohorts;
mod handlers_discussions;
mod handlers_notes;
mod handlers_payments;
@@ -150,6 +151,21 @@ async fn main() {
)
.route("/lessons/{id}/notes", get(handlers_notes::get_note))
.route("/lessons/{id}/notes", put(handlers_notes::save_note))
// Cohorts
.route("/cohorts", get(handlers_cohorts::list_cohorts))
.route("/cohorts", post(handlers_cohorts::create_cohort))
.route(
"/cohorts/{id}/members",
post(handlers_cohorts::add_cohort_member),
)
.route(
"/cohorts/{cohort_id}/members/{user_id}",
delete(handlers_cohorts::remove_cohort_member),
)
.route(
"/cohorts/{id}/members",
get(handlers_cohorts::get_cohort_members),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
));