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:
@@ -0,0 +1,26 @@
|
||||
-- Cohorts table
|
||||
CREATE TABLE IF NOT EXISTS cohorts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT cohorts_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- User-Cohort relationship table (M:N)
|
||||
CREATE TABLE IF NOT EXISTS user_cohorts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
cohort_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT user_cohorts_cohort_id_fkey FOREIGN KEY (cohort_id) REFERENCES cohorts(id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_cohorts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_cohorts_unique UNIQUE (cohort_id, user_id)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_cohorts_organization_id ON cohorts(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_cohorts_cohort_id ON user_cohorts(cohort_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_cohorts_user_id ON user_cohorts(user_id);
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user