feat: Add comprehensive peer assessment functionality including new data models, API endpoints, database migrations, and dedicated UI components for Studio and Experience applications.

This commit is contained in:
2026-02-16 20:22:02 -03:00
parent cb13b14ee0
commit 1d7e5a39ce
12 changed files with 750 additions and 12 deletions
@@ -0,0 +1,31 @@
-- Create table for student submissions
CREATE TABLE IF NOT EXISTS course_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
content TEXT NOT NULL, -- Could be text or URL
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
UNIQUE(user_id, lesson_id)
);
-- Create table for peer reviews
CREATE TABLE IF NOT EXISTS peer_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES course_submissions(id) ON DELETE CASCADE,
reviewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
feedback TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
UNIQUE(submission_id, reviewer_id)
);
-- Create indexes for faster queries
CREATE INDEX IF NOT EXISTS idx_course_submissions_user_lesson ON course_submissions(user_id, lesson_id);
CREATE INDEX IF NOT EXISTS idx_course_submissions_course ON course_submissions(course_id);
CREATE INDEX IF NOT EXISTS idx_peer_reviews_submission ON peer_reviews(submission_id);
CREATE INDEX IF NOT EXISTS idx_peer_reviews_reviewer ON peer_reviews(reviewer_id);
@@ -0,0 +1,203 @@
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::models::{
CourseSubmission, PeerReview, SubmitAssignmentPayload, SubmitPeerReviewPayload,
};
use common::{auth::Claims, middleware::Org};
use sqlx::PgPool;
use uuid::Uuid;
pub async fn submit_assignment(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SubmitAssignmentPayload>,
) -> Result<Json<CourseSubmission>, (StatusCode, String)> {
// Check if submission already exists
let existing: Option<CourseSubmission> = sqlx::query_as!(
CourseSubmission,
"SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
claims.sub,
lesson_id
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(_) = existing {
// Update existing submission
let updated = sqlx::query_as!(
CourseSubmission,
r#"
UPDATE course_submissions
SET content = $1, updated_at = NOW()
WHERE user_id = $2 AND lesson_id = $3
RETURNING *
"#,
payload.content,
claims.sub,
lesson_id
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
return Ok(Json(updated));
}
// Create new submission
let submission = sqlx::query_as!(
CourseSubmission,
r#"
INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
claims.sub,
course_id,
lesson_id,
org_ctx.id,
payload.content
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(submission))
}
pub async fn get_peer_review_assignment(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Option<CourseSubmission>>, (StatusCode, String)> {
// Find a submission that:
// 1. Is not my own
// 2. Has fewer than 2 reviews (configurable, but hardcoded for now)
// 3. I haven't reviewed yet
let submission = sqlx::query_as!(
CourseSubmission,
r#"
SELECT s.*
FROM course_submissions s
LEFT JOIN peer_reviews pr ON s.id = pr.submission_id
WHERE s.course_id = $1
AND s.lesson_id = $2
AND s.user_id != $3
AND s.organization_id = $4
AND NOT EXISTS (
SELECT 1 FROM peer_reviews my_pr
WHERE my_pr.submission_id = s.id AND my_pr.reviewer_id = $3
)
GROUP BY s.id
HAVING COUNT(pr.id) < 2
ORDER BY s.submitted_at ASC
LIMIT 1
"#,
course_id,
lesson_id,
claims.sub,
org_ctx.id
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(submission))
}
pub async fn submit_peer_review(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((_course_id, _lesson_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SubmitPeerReviewPayload>,
) -> Result<Json<PeerReview>, (StatusCode, String)> {
// Verify valid submission
let submission = sqlx::query!(
"SELECT user_id FROM course_submissions WHERE id = $1",
payload.submission_id
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let submission = match submission {
Some(s) => s,
None => return Err((StatusCode::NOT_FOUND, "Submission not found".to_string())),
};
if submission.user_id == claims.sub {
return Err((
StatusCode::BAD_REQUEST,
"Cannot review your own submission".to_string(),
));
}
// Check if already reviewed
let existing = sqlx::query!(
"SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
payload.submission_id,
claims.sub
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if existing.is_some() {
return Err((
StatusCode::CONFLICT,
"You have already reviewed this submission".to_string(),
));
}
// Create review
let review = sqlx::query_as!(
PeerReview,
r#"
INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#,
payload.submission_id,
claims.sub,
payload.score,
payload.feedback,
org_ctx.id
)
.fetch_one(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(review))
}
pub async fn get_my_submission_feedback(
Org(org_ctx): Org,
claims: Claims,
State(pool): State<PgPool>,
Path((_course_id, lesson_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<Vec<PeerReview>>, (StatusCode, String)> {
// Get reviews for my submission on this lesson
let reviews = sqlx::query_as!(
PeerReview,
r#"
SELECT pr.*
FROM peer_reviews pr
JOIN course_submissions cs ON pr.submission_id = cs.id
WHERE cs.user_id = $1 AND cs.lesson_id = $2
"#,
claims.sub,
lesson_id
)
.fetch_all(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(reviews))
}
+18
View File
@@ -5,6 +5,7 @@ mod handlers_cohorts;
mod handlers_discussions;
mod handlers_notes;
mod handlers_payments;
mod handlers_peer_review;
use axum::{
Router, middleware,
@@ -167,6 +168,23 @@ async fn main() {
"/cohorts/{id}/members",
get(handlers_cohorts::get_cohort_members),
)
// Peer Assessment
.route(
"/courses/{id}/lessons/{lesson_id}/submit",
post(handlers_peer_review::submit_assignment),
)
.route(
"/courses/{id}/lessons/{lesson_id}/peer-review",
get(handlers_peer_review::get_peer_review_assignment),
)
.route(
"/courses/{id}/lessons/{lesson_id}/peer-review",
post(handlers_peer_review::submit_peer_review),
)
.route(
"/courses/{id}/lessons/{lesson_id}/feedback",
get(handlers_peer_review::get_my_submission_feedback),
)
.route_layer(middleware::from_fn(
common::middleware::org_extractor_middleware,
));