feat: Implement LTI deep linking, live sessions, predictive analytics, and portfolios with associated UI and database migrations.
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n u.id, \n u.full_name, \n u.email, \n 0.0::float4 as progress,\n (SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name,\n AVG(g.score)::float4 as average_score\n FROM users u\n JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1\n LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1\n WHERE e.organization_id = $2\n GROUP BY u.id, u.full_name, u.email\n ORDER BY u.full_name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "full_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "progress",
|
||||
"type_info": "Float4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "cohort_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "average_score",
|
||||
"type_info": "Float4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "17f05eb41a9b8c4fd37f1c47495546709658ff09ac8be7ad1c611039e55394da"
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE course_submissions \n SET content = $1, updated_at = NOW() \n WHERE user_id = $2 AND lesson_id = $3\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "course_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "submitted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "363c5ded702de620f7d55d9a28564a6cb07ab2123f6733eb9992ef29a0347a3f"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT user_id FROM course_submissions WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "48092c69f6c0c66fc843d31c65123dfbd6771c450e34d2f330a7cec4cad9e16e"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT cohort_id FROM announcement_cohorts WHERE announcement_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cohort_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5e0c0dd74a0fcb24eae0b69b46550cdb1fc0520f59ab24095319c844100696a8"
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT s.* \n FROM course_submissions s\n LEFT JOIN peer_reviews pr ON s.id = pr.submission_id\n WHERE s.course_id = $1 \n AND s.lesson_id = $2\n AND s.user_id != $3\n AND s.organization_id = $4\n AND NOT EXISTS (\n SELECT 1 FROM peer_reviews my_pr \n WHERE my_pr.submission_id = s.id AND my_pr.reviewer_id = $3\n )\n GROUP BY s.id\n HAVING COUNT(pr.id) < 2\n ORDER BY s.submitted_at ASC\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "course_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "submitted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "60ddd0622ba9bdb70c661f9b8f755f4336017efa585e1f93ac848b1fed4835a1"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT ld.prerequisite_lesson_id, p.title as prereq_title, ld.min_score_percentage\n FROM lesson_dependencies ld\n JOIN lessons p ON ld.prerequisite_lesson_id = p.id\n LEFT JOIN user_grades ug ON ld.prerequisite_lesson_id = ug.lesson_id AND ug.user_id = $2\n LEFT JOIN lesson_interactions li ON ld.prerequisite_lesson_id = li.lesson_id \n AND li.user_id = $2 AND li.event_type = 'complete'\n WHERE ld.lesson_id = $1\n AND (\n (p.is_graded = true AND (ug.score IS NULL OR (ug.score * 100.0) < COALESCE(ld.min_score_percentage, 0.0)))\n OR\n (p.is_graded = false AND li.id IS NULL)\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "prerequisite_lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "prereq_title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "min_score_percentage",
|
||||
"type_info": "Float8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6744490d98f0f7b1d753e89dfe2cddef4e580c62954847940e5fa0d1ad6a7fcf"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT ld.* \n FROM lesson_dependencies ld\n JOIN lessons l ON ld.lesson_id = l.id\n JOIN modules m ON l.module_id = m.id\n WHERE m.course_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "prerequisite_lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "min_score_percentage",
|
||||
"type_info": "Float8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "82038007a13b07cdb912619ddefa10faa651a934e8dfb14be29226b85c614cae"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, name FROM grading_categories WHERE course_id = $1 ORDER BY name",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "af5539604d4c172890ce3f62de6dbe7952f027d6caf367ed0480b5f16274bd1f"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id FROM peer_reviews WHERE submission_id = $1 AND reviewer_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bba15398004acf73d991751221eba784335db7d899c0601d216fc1703ff49d06"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT * FROM course_submissions WHERE user_id = $1 AND lesson_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "course_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "submitted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bd51fbc3e8b3746722d88201f1937ffa3f2718790d55d4b9143bacaa6219173e"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO peer_reviews (submission_id, reviewer_id, score, feedback, organization_id)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "submission_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "reviewer_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "score",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "feedback",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bf3c31d22790fe0eeec0234f97f920a3c077a443579c6184f09b95f9d078f593"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT \n g.user_id, \n l.grading_category_id, \n AVG(g.score)::float4 as avg_score\n FROM user_grades g\n JOIN lessons l ON g.lesson_id = l.id\n WHERE g.course_id = $1\n GROUP BY g.user_id, l.grading_category_id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "grading_category_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "avg_score",
|
||||
"type_info": "Float4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1778b5abe6d4e799993ac6145d1c00bd5d73086e80b9e068010f29202b1f5a8"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT pr.* \n FROM peer_reviews pr\n JOIN course_submissions cs ON pr.submission_id = cs.id\n WHERE cs.user_id = $1 AND cs.lesson_id = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "submission_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "reviewer_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "score",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "feedback",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "da54efaca96ea7a75198417d4ce1754aaad514c9877a264449807338a1f539b8"
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO course_submissions (user_id, course_id, lesson_id, organization_id, content)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "course_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "lesson_id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "submitted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "organization_id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e0cf43306025c312338d7f507911fccb61f18ddd6bf8f9c90f368a296f346f4b"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDB4h2jpCSUglbv
|
||||
ugQB3Q29f8vV7qYVGkdtKuFaMQlN9APMt01XzjM9+76s/4gl1aW+u07YX90cdqHS
|
||||
KU1FkJoDA9c2pLovns6tgmPF4Ncpaed9pXDi5RPVoMYVxdDnkFt/Tn43bxqPbo0R
|
||||
eH1ZBWo7ovxdGuV5FSAm1vwwEBai/EqLcIEn+UVVh7fVw94A4JoQX/HN4xUSYm3d
|
||||
9tJ7CxQisHuMPwXdg5y7yH9AYBz22f27Ex8Em/ZdmgYcK0xBQYbTuJm2hIoFgatZ
|
||||
/msFzs+wIVxW5DOU71x/XeOBDatx9xTcvAL+CngD1Fie//KZocQvPSQAsoG0HGPV
|
||||
dTtB0uuzAgMBAAECggEACeDGV88GGhbl7QLSL3IewBfhv72P8qVLHFHB94FmEvWq
|
||||
t+Ri6WVVEQRhe5jtS9gtwKD0bGu97TRSHE4EZNXwMtUgQuVzovd5Wje5c5x3+eEu
|
||||
bSVfsf+v2gN99CAG+7VMJBlQruxXMxWT1F8KK93twqoUJ342UMv1vmTXpm87aARR
|
||||
56uRb9PywQrJbxfC1Lg5kB5LMYdeyKmuoUNg+EP/4YhIbWDyLBHIT+TLjXN8sA01
|
||||
TXXatqL5X8BxjvgIYUdILLQcJ+IsumGbR1wM02XzZbIaMlIuD/ey1GLSvr2Q1jBP
|
||||
YgBCYLql809SPlyopj1W+r9TjulPtJ/I4pl5kgSL8QKBgQDiOg4ydyZt/ESzUQ2Z
|
||||
Wp2Z5Mc9w6fnaPk/Wf8GYuopbWmMIAO0KsoBYgjuZW3bnEl0yZ7r+NtqiWciWp6n
|
||||
C4HFW5ymAzp2zrPQkrNw2TL1QQ8toE8G1AApifc7QVKxDgVeKibF0wtR1bSSOhlt
|
||||
wqzIYO9sLXI6jrcRmQr4qZWYWwKBgQDbZlrwW06x26KqNfURA5B99XqMVrRK3tBb
|
||||
S3Y1KLELZ8BiEeLgdLPnboR++OvN7LR3QgWNP5rS7DXbIy5kPzSQyPlO3G+FBqFC
|
||||
SP4j2SH21Cj2LNrqMW1WERr5Zh44lpyRB2g0bbKBCXSHeP7Sg4qMoNCle6CuWS/X
|
||||
6x4dOAKZiQKBgB/+4AUpLuk9VaYa35aB52pdngRRSM0E3sOkAdqwYLftPpFP8dYo
|
||||
exuI9wRomgoGZ6k53t02/ClsN4b3VBsCGJ+GHnioWjt1bp8gMHrUbU2cnv3v/11S
|
||||
3JcDaVEbIwvhlMbFpWgzOhWf6QMJbpFEiFVqyFH/d3lqt9+oSpHywjKjAoGBALpO
|
||||
vMKGhtj5zbQEhcqg4D5WCm7J4egCNaSQ/BxAJbetruyYi7RW5b6NVu4LqxH/A3CS
|
||||
G+zKKksaUtF3mpl+IsEgKLUS85BfBOko2sbOR802dGI3zN46gsInXGSUlu0u2F0/
|
||||
kPmUfZSd1tqDoMBa+3hXx1X/GX90NPCBs9zUB0EhAoGAIyebw+Qu75MuOiaeLXO2
|
||||
i+9Lp/WRT3WnR4CCXgnaTlB2V2bfYsSDPmFPxbiZsB/Cvj0lEQzzHLNOV6QXEG78
|
||||
OOjPU1HRRg3czbBPYof0J9oTxTM8s8+Tw82uT+7yb+OyyV5Yu/o22kcvA0YToMwP
|
||||
TNX0s8zakZzCaFpIdGzVOUk=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAweIdo6QklIJW77oEAd0N
|
||||
vX/L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu+rP+IJdWlvrtO2F/dHHah0ilNRZCa
|
||||
AwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05+N28aj26NEXh9WQVq
|
||||
O6L8XRrleRUgJtb8MBAWovxKi3CBJ/lFVYe31cPeAOCaEF/xzeMVEmJt3fbSewsU
|
||||
IrB7jD8F3YOcu8h/QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7P
|
||||
sCFcVuQzlO9cf13jgQ2rcfcU3LwC/gp4A9RYnv/ymaHELz0kALKBtBxj1XU7QdLr
|
||||
swIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
weIdo6QklIJW77oEAd0NvX_L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu-rP-IJdWlvrtO2F_dHHah0ilNRZCaAwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05-N28aj26NEXh9WQVqO6L8XRrleRUgJtb8MBAWovxKi3CBJ_lFVYe31cPeAOCaEF_xzeMVEmJt3fbSewsUIrB7jD8F3YOcu8h_QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7PsCFcVuQzlO9cf13jgQ2rcfcU3LwC_gp4A9RYnv_ymaHELz0kALKBtBxj1XU7QdLrsw
|
||||
@@ -0,0 +1 @@
|
||||
C1E21DA3A424948256EFBA0401DD0DBD7FCBD5EEA6151A476D2AE15A31094DF403CCB74D57CE333DFBBEACFF8825D5A5BEBB4ED85FDD1C76A1D2294D45909A0303D736A4BA2F9ECEAD8263C5E0D72969E77DA570E2E513D5A0C615C5D0E7905B7F4E7E376F1A8F6E8D11787D59056A3BA2FC5D1AE579152026D6FC301016A2FC4A8B708127F9455587B7D5C3DE00E09A105FF1CDE31512626DDDF6D27B0B1422B07B8C3F05DD839CBBC87F40601CF6D9FDBB131F049BF65D9A061C2B4C414186D3B899B6848A0581AB59FE6B05CECFB0215C56E43394EF5C7F5DE3810DAB71F714DCBC02FE0A7803D4589EFFF299A1C42F3D2400B281B41C63D5753B41D2EBB3
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE lesson_dependencies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
prerequisite_lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||
min_score_percentage DOUBLE PRECISION,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(lesson_id, prerequisite_lesson_id),
|
||||
CHECK (lesson_id != prerequisite_lesson_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_lesson_dependencies_lesson_id ON lesson_dependencies(lesson_id);
|
||||
CREATE INDEX idx_lesson_dependencies_prerequisite_id ON lesson_dependencies(prerequisite_lesson_id);
|
||||
CREATE INDEX idx_lesson_dependencies_org_id ON lesson_dependencies(organization_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration: Add LTI Deep Linking support tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS lti_deep_linking_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
registration_id UUID NOT NULL REFERENCES lti_registrations(id),
|
||||
deployment_id TEXT NOT NULL,
|
||||
return_url TEXT NOT NULL,
|
||||
data TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for cleanup
|
||||
CREATE INDEX idx_lti_dl_requests_created_at ON lti_deep_linking_requests(created_at);
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TYPE dropout_risk_level AS ENUM ('low', 'medium', 'high', 'critical');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dropout_risks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id),
|
||||
course_id UUID NOT NULL REFERENCES courses(id),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
risk_level dropout_risk_level NOT NULL DEFAULT 'low',
|
||||
score REAL NOT NULL DEFAULT 0.0,
|
||||
reasons JSONB,
|
||||
last_calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(course_id, user_id)
|
||||
);
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE TRIGGER update_dropout_risks_updated_at
|
||||
BEFORE UPDATE ON dropout_risks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE meetings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL REFERENCES organizations(id),
|
||||
course_id UUID NOT NULL REFERENCES courses(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
provider TEXT NOT NULL DEFAULT 'jitsi',
|
||||
meeting_id TEXT NOT NULL,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
join_url TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for quick lookup of course meetings
|
||||
CREATE INDEX idx_meetings_course ON meetings(course_id);
|
||||
|
||||
-- Trigger for updated_at
|
||||
CREATE TRIGGER update_meetings_updated_at
|
||||
BEFORE UPDATE ON meetings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Migration: Portfolios & Badges (Adjustments)
|
||||
-- This migration adjusts existing gamification tables to support the new features
|
||||
|
||||
-- 1. Adjust badges table
|
||||
ALTER TABLE badges ADD COLUMN IF NOT EXISTS criteria JSONB NOT NULL DEFAULT '{}';
|
||||
-- Ensure organization_id has a foreign key if it's missing (optional but good)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'badges_organization_id_fkey') THEN
|
||||
ALTER TABLE badges ADD CONSTRAINT badges_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 2. Adjust user_badges table
|
||||
ALTER TABLE user_badges ADD COLUMN IF NOT EXISTS evidence_url TEXT;
|
||||
-- Rename earned_at to awarded_at if needed, or just use earned_at in code.
|
||||
-- The model currently expects awarded_at. Let's rename if exists.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_badges' AND column_name='earned_at') THEN
|
||||
ALTER TABLE user_badges RENAME COLUMN earned_at TO awarded_at;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 3. Add profile visibility to users
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_public_profile BOOLEAN DEFAULT true;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS linkedin_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS github_url TEXT;
|
||||
|
||||
-- 4. Seed some extra default badges if not present
|
||||
INSERT INTO badges (organization_id, name, description, icon_url, requirement_type, requirement_value)
|
||||
SELECT id, 'Open Source Contributor', 'Linked a GitHub account to your profile', '/badges/github.svg', 'points', 0
|
||||
FROM organizations
|
||||
WHERE NOT EXISTS (SELECT 1 FROM badges WHERE name = 'Open Source Contributor')
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO badges (organization_id, name, description, icon_url, requirement_type, requirement_value)
|
||||
SELECT id, 'Networking Pro', 'Linked a LinkedIn account to your profile', '/badges/linkedin.svg', 'points', 0
|
||||
FROM organizations
|
||||
WHERE NOT EXISTS (SELECT 1 FROM badges WHERE name = 'Networking Pro')
|
||||
LIMIT 1;
|
||||
@@ -10,6 +10,7 @@ use common::middleware::Org;
|
||||
use common::models::{
|
||||
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
|
||||
Module, Notification, Organization, RecommendationResponse, User, UserResponse,
|
||||
LessonDependency,
|
||||
};
|
||||
|
||||
pub async fn get_me(
|
||||
@@ -20,7 +21,7 @@ pub async fn get_me(
|
||||
.bind(claims.sub)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
@@ -156,7 +157,7 @@ pub async fn export_course_grades(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 2. Get Student general data
|
||||
let students = sqlx::query!(
|
||||
@@ -165,14 +166,14 @@ pub async fn export_course_grades(
|
||||
u.id,
|
||||
u.full_name,
|
||||
u.email,
|
||||
COALESCE(e.progress, 0)::float4 as progress,
|
||||
0.0::float4 as progress,
|
||||
(SELECT name FROM cohorts c JOIN user_cohorts uc ON c.id = uc.cohort_id WHERE uc.user_id = u.id LIMIT 1) as cohort_name,
|
||||
AVG(g.score)::float4 as average_score
|
||||
FROM users u
|
||||
JOIN enrollments e ON u.id = e.user_id AND e.course_id = $1
|
||||
LEFT JOIN user_grades g ON u.id = g.user_id AND g.course_id = $1
|
||||
WHERE e.organization_id = $2
|
||||
GROUP BY u.id, u.full_name, u.email, e.progress
|
||||
GROUP BY u.id, u.full_name, u.email
|
||||
ORDER BY u.full_name
|
||||
"#,
|
||||
course_id,
|
||||
@@ -180,7 +181,7 @@ pub async fn export_course_grades(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Get detailed grades per user/category
|
||||
struct UserCategoryGrade {
|
||||
@@ -205,7 +206,7 @@ pub async fn export_course_grades(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 4. Build CSV
|
||||
let mut csv = "Name,Email,Cohort,Progress,Overall Score".to_string();
|
||||
@@ -216,7 +217,7 @@ pub async fn export_course_grades(
|
||||
|
||||
for s in students {
|
||||
let cohort = s.cohort_name.unwrap_or_else(|| "N/A".to_string());
|
||||
let progress = format!("{:.1}%", s.progress * 100.0);
|
||||
let progress = format!("{:.1}%", s.progress.unwrap_or(0.0) * 100.0);
|
||||
let overall = s
|
||||
.average_score
|
||||
.map(|v| format!("{:.1}%", v * 100.0))
|
||||
@@ -327,7 +328,7 @@ pub async fn enroll_user(
|
||||
.bind(course_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Enrollment failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -415,7 +416,7 @@ pub async fn register(
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let organization = if let Some(org_name) = payload.organization_name {
|
||||
sqlx::query_as::<_, Organization>(
|
||||
@@ -424,7 +425,7 @@ pub async fn register(
|
||||
.bind(&org_name)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al buscar o crear la organización: {}", e)))?
|
||||
} else {
|
||||
sqlx::query_as::<_, Organization>(
|
||||
"SELECT * FROM organizations WHERE id = '00000000-0000-0000-0000-000000000001'",
|
||||
@@ -448,11 +449,11 @@ pub async fn register(
|
||||
.bind(organization.id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::CONFLICT, format!("El usuario ya existe o error en la BD: {}", e)))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let token = create_jwt(user.id, user.organization_id, "student").map_err(|_| {
|
||||
(
|
||||
@@ -570,7 +571,7 @@ pub async fn get_course_catalog(
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Catalog fetch failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -612,7 +613,7 @@ pub async fn ingest_course(
|
||||
.bind(payload.organization.updated_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to upsert organization during ingestion: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -650,7 +651,7 @@ pub async fn ingest_course(
|
||||
.bind(&payload.course.currency)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to upsert course during ingestion: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -706,7 +707,7 @@ pub async fn ingest_course(
|
||||
.bind(instructor.created_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to insert instructor: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -755,7 +756,7 @@ pub async fn ingest_course(
|
||||
.bind(lesson.is_previewable)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to insert lesson: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -817,7 +818,7 @@ pub async fn get_course_outline(
|
||||
.bind(id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_course_outline: course fetch failed for {}: {}", id, e);
|
||||
StatusCode::NOT_FOUND
|
||||
})?;
|
||||
@@ -830,7 +831,7 @@ pub async fn get_course_outline(
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_course_outline: modules fetch failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -844,7 +845,7 @@ pub async fn get_course_outline(
|
||||
.bind(course.organization_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!(
|
||||
"get_course_outline: organization fetch failed for {}: {}",
|
||||
course.organization_id,
|
||||
@@ -865,7 +866,7 @@ pub async fn get_course_outline(
|
||||
.bind(id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_course_outline: grading categories fetch failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -879,7 +880,7 @@ pub async fn get_course_outline(
|
||||
.bind(module.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!(
|
||||
"get_course_outline: lessons fetch failed for module {}: {}",
|
||||
module.id,
|
||||
@@ -905,7 +906,7 @@ pub async fn get_course_outline(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_course_outline: dependencies fetch failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -959,7 +960,7 @@ pub async fn get_lesson_content(
|
||||
.bind(claims.org)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_lesson_content: DB error (preview): {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
@@ -974,7 +975,7 @@ pub async fn get_lesson_content(
|
||||
.bind(claims.sub)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_lesson_content: DB error: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
@@ -1020,7 +1021,7 @@ pub async fn get_lesson_content(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("get_lesson_content: failed to check dependencies: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1073,7 +1074,7 @@ pub async fn submit_lesson_score(
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let ip = headers
|
||||
.get("x-forwarded-for")
|
||||
@@ -1095,7 +1096,7 @@ pub async fn submit_lesson_score(
|
||||
Some("SYSTEM_EVENT".to_string()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 1. Get lesson attempt rules
|
||||
let max_attempts: Option<Option<i32>> =
|
||||
@@ -1103,7 +1104,7 @@ pub async fn submit_lesson_score(
|
||||
.bind(payload.lesson_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if max_attempts.is_none() {
|
||||
return Err((StatusCode::NOT_FOUND, "Lección no encontrada".into()));
|
||||
@@ -1117,7 +1118,7 @@ pub async fn submit_lesson_score(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(count) = existing_attempts {
|
||||
if let Some(max) = max_attempts {
|
||||
@@ -1142,11 +1143,11 @@ pub async fn submit_lesson_score(
|
||||
.bind(payload.metadata)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 4. Dispatch Webhooks
|
||||
let webhook_service = common::webhooks::WebhookService::new(pool.clone());
|
||||
@@ -1254,7 +1255,7 @@ pub async fn get_leaderboard(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to fetch leaderboard: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1310,7 +1311,7 @@ pub async fn get_course_grades(
|
||||
.bind(filter.cohort_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to fetch course grades: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||
})?;
|
||||
@@ -1362,7 +1363,7 @@ pub async fn get_course_analytics(
|
||||
.bind(filter.cohort_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 2. Average Course Score (Overall)
|
||||
let average_score: Option<f32> = sqlx::query_scalar(
|
||||
@@ -1381,7 +1382,7 @@ pub async fn get_course_analytics(
|
||||
.bind(filter.cohort_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 3. Per-Lesson Analytics
|
||||
// Note: We cast AVG to float4 for PostgreSQL compatibility
|
||||
@@ -1407,7 +1408,7 @@ pub async fn get_course_analytics(
|
||||
.bind(filter.cohort_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let lessons = rows
|
||||
.into_iter()
|
||||
@@ -1529,7 +1530,7 @@ pub async fn get_advanced_analytics(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Cohort query failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1542,7 +1543,7 @@ pub async fn get_advanced_analytics(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Retention query failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1572,7 +1573,7 @@ pub async fn record_interaction(
|
||||
.bind(payload.metadata)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to record interaction: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1596,7 +1597,7 @@ pub async fn get_lesson_heatmap(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to fetch heatmap: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1616,7 +1617,7 @@ pub async fn get_notifications(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to fetch notifications: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1638,7 +1639,7 @@ pub async fn mark_notification_as_read(
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
.map_err(|e: sqlx::Error| {
|
||||
tracing::error!("Failed to mark notification as read: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
@@ -1702,7 +1703,7 @@ pub async fn toggle_bookmark(
|
||||
.bind(lesson_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(id) = existing_id {
|
||||
// Remove bookmark
|
||||
@@ -1710,7 +1711,7 @@ pub async fn toggle_bookmark(
|
||||
.bind(id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
// Add bookmark
|
||||
@@ -1723,7 +1724,7 @@ pub async fn toggle_bookmark(
|
||||
.bind(lesson_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
}
|
||||
@@ -1745,7 +1746,7 @@ pub async fn get_user_bookmarks(
|
||||
// Wait, let's create a better filter for this.
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(bookmarks))
|
||||
}
|
||||
@@ -1777,7 +1778,7 @@ pub async fn update_user(
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
@@ -1809,7 +1810,7 @@ pub async fn get_recommendations(
|
||||
.bind(course_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 2. Fetch lesson metadata (titles and tags) for context
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -26,7 +26,7 @@ pub async fn submit_assignment(
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if let Some(_) = existing {
|
||||
// Update existing submission
|
||||
@@ -44,7 +44,7 @@ pub async fn submit_assignment(
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
return Ok(Json(updated));
|
||||
}
|
||||
@@ -65,7 +65,7 @@ pub async fn submit_assignment(
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(submission))
|
||||
}
|
||||
@@ -106,7 +106,7 @@ pub async fn get_peer_review_assignment(
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(submission))
|
||||
}
|
||||
@@ -125,7 +125,7 @@ pub async fn submit_peer_review(
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let submission = match submission {
|
||||
Some(s) => s,
|
||||
@@ -147,7 +147,7 @@ pub async fn submit_peer_review(
|
||||
)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err((
|
||||
@@ -172,7 +172,7 @@ pub async fn submit_peer_review(
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(review))
|
||||
}
|
||||
@@ -197,7 +197,7 @@ pub async fn get_my_submission_feedback(
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(reviews))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use jsonwebtoken::jwk::{JwkSet, Jwk, CommonParameters, RSAKeyParameters, AlgorithmParameters};
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
|
||||
pub fn get_lti_private_key() -> jsonwebtoken::EncodingKey {
|
||||
let key_str = env::var("LTI_PRIVATE_KEY").unwrap_or_else(|_| {
|
||||
// Fallback for development (DO NOT USE IN PRODUCTION)
|
||||
include_str!("../dev_keys/lti_private.pem").to_string()
|
||||
});
|
||||
|
||||
jsonwebtoken::EncodingKey::from_rsa_pem(key_str.as_bytes()).expect("Invalid LTI private key")
|
||||
}
|
||||
|
||||
pub fn get_lti_jwks() -> JwkSet {
|
||||
let n = env::var("LTI_JWK_N").unwrap_or_else(|_| {
|
||||
"weIdo6QklIJW77oEAd0NvX_L1e6mFRpHbSrhWjEJTfQDzLdNV84zPfu-rP-IJdWlvrtO2F_dHHah0ilNRZCaAwPXNqS6L57OrYJjxeDXKWnnfaVw4uUT1aDGFcXQ55Bbf05-N28aj26NEXh9WQVqO6L8XRrleRUgJtb8MBAWovxKi3CBJ_lFVYe31cPeAOCaEF_xzeMVEmJt3fbSewsUIrB7jD8F3YOcu8h_QGAc9tn9uxMfBJv2XZoGHCtMQUGG07iZtoSKBYGrWf5rBc7PsCF_VuQzlO9cf13jgQ2rcfcU3LwC_gp4A9RYnv_ymaHELz0kALKBtBxj1XU7QdLrsw".to_string()
|
||||
});
|
||||
|
||||
let jwk = Jwk {
|
||||
common: CommonParameters {
|
||||
public_key_use: Some(jsonwebtoken::jwk::PublicKeyUse::Signature),
|
||||
key_operations: None,
|
||||
key_algorithm: Some(jsonwebtoken::jwk::KeyAlgorithm::RS256),
|
||||
key_id: Some("openccb-lti-key-1".to_string()),
|
||||
x509_url: None,
|
||||
x509_chain: None,
|
||||
x509_sha1_fingerprint: None,
|
||||
x509_sha256_fingerprint: None,
|
||||
},
|
||||
algorithm: AlgorithmParameters::RSA(RSAKeyParameters {
|
||||
key_type: jsonwebtoken::jwk::RSAKeyType::RSA,
|
||||
n,
|
||||
e: "AQAB".to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
JwkSet { keys: vec![jwk] }
|
||||
}
|
||||
|
||||
pub async fn lti_jwks_handler() -> axum::Json<serde_json::Value> {
|
||||
let jwks = get_lti_jwks();
|
||||
axum::Json(json!(jwks))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use common::auth::Claims;
|
||||
use common::models::Meeting;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateMeetingPayload {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub start_at: chrono::DateTime<Utc>,
|
||||
pub duration_minutes: i32,
|
||||
}
|
||||
|
||||
pub async fn get_course_meetings(
|
||||
Path(course_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
claims: Claims,
|
||||
) -> Result<Json<Vec<Meeting>>, (StatusCode, String)> {
|
||||
let meetings = sqlx::query_as::<sqlx::Postgres, Meeting>(
|
||||
"SELECT * FROM meetings WHERE course_id = $1 AND organization_id = $2 ORDER BY start_at ASC"
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(claims.org)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(meetings))
|
||||
}
|
||||
|
||||
pub async fn create_meeting(
|
||||
Path(course_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
claims: Claims,
|
||||
Json(payload): Json<CreateMeetingPayload>,
|
||||
) -> Result<Json<Meeting>, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only instructors can create meetings".to_string()));
|
||||
}
|
||||
|
||||
let meeting_id = format!("openccb-{}", Uuid::new_v4());
|
||||
let join_url = format!("https://meet.jit.si/{}", meeting_id);
|
||||
|
||||
let meeting = sqlx::query_as::<sqlx::Postgres, Meeting>(
|
||||
r#"
|
||||
INSERT INTO meetings (organization_id, course_id, title, description, provider, meeting_id, start_at, duration_minutes, join_url)
|
||||
VALUES ($1, $2, $3, $4, 'jitsi', $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(claims.org)
|
||||
.bind(course_id)
|
||||
.bind(payload.title)
|
||||
.bind(payload.description)
|
||||
.bind(meeting_id)
|
||||
.bind(payload.start_at)
|
||||
.bind(payload.duration_minutes)
|
||||
.bind(join_url)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(meeting))
|
||||
}
|
||||
|
||||
pub async fn delete_meeting(
|
||||
Path((_course_id, meeting_id)): Path<(Uuid, Uuid)>,
|
||||
State(pool): State<PgPool>,
|
||||
claims: Claims,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only instructors can delete meetings".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM meetings WHERE id = $1 AND organization_id = $2")
|
||||
.bind(meeting_id)
|
||||
.bind(claims.org)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -205,51 +205,108 @@ pub async fn lti_launch(
|
||||
|
||||
let user = user.unwrap();
|
||||
|
||||
// 6. Map resource link to course
|
||||
let resource_link = sqlx::query_as::<_, LtiResourceLink>(
|
||||
"SELECT * FROM lti_resource_links WHERE organization_id = $1 AND resource_link_id = $2"
|
||||
)
|
||||
.bind(registration.organization_id)
|
||||
.bind(<i_claims.resource_link.id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
// 8. Redirect based on message type
|
||||
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
let studio_url = std::env::var("NEXT_PUBLIC_STUDIO_URL").unwrap_or_else(|_| "http://localhost:3001".to_string());
|
||||
|
||||
let redirect_target = if let Some(link) = resource_link {
|
||||
let token = common::auth::create_jwt(user.id, user.organization_id, &user.role)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create token: {}", e)))?;
|
||||
let redirect_target = lti_claims.resource_link.as_ref().map(|rl| rl.id.clone()).unwrap_or_default();
|
||||
|
||||
if lti_claims.message_type == "LtiDeepLinkingRequest" {
|
||||
let settings = lti_claims.deep_linking_settings.ok_or((StatusCode::BAD_REQUEST, "Missing deep_linking_settings".to_string()))?;
|
||||
|
||||
let dl_request_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO enrollments (user_id, organization_id, course_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING"
|
||||
"INSERT INTO lti_deep_linking_requests (id, registration_id, deployment_id, return_url, data) VALUES ($1, $2, $3, $4, $5)"
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(registration.organization_id)
|
||||
.bind(link.course_id)
|
||||
.bind(dl_request_id)
|
||||
.bind(registration.id)
|
||||
.bind(<i_claims.deployment_id)
|
||||
.bind(&settings.deep_link_return_url)
|
||||
.bind(&settings.data)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
format!("/courses/{}", link.course_id)
|
||||
Ok(Redirect::to(&format!("{}/lti/deep-linking?token={}&dl_token={}", studio_url, token, dl_request_id)))
|
||||
} else {
|
||||
"/dashboard".to_string()
|
||||
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
|
||||
}
|
||||
}
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LtiDeepLinkingResponsePayload {
|
||||
pub dl_token: String,
|
||||
pub items: Vec<common::models::LtiDeepLinkingContentItem>,
|
||||
}
|
||||
|
||||
pub async fn lti_deep_linking_response(
|
||||
State(pool): State<PgPool>,
|
||||
claims: Claims,
|
||||
Json(payload): Json<LtiDeepLinkingResponsePayload>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
// 1. Retrieve and delete DL request
|
||||
let dl_id = Uuid::parse_str(&payload.dl_token).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid DL token".to_string()))?;
|
||||
|
||||
let dl_request = sqlx::query(
|
||||
"DELETE FROM lti_deep_linking_requests WHERE id = $1 RETURNING registration_id, deployment_id, return_url, data"
|
||||
)
|
||||
.bind(dl_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Invalid or expired DL request".to_string()))?;
|
||||
|
||||
// Manual mapping since we can't use query!/query_as! easily for RETURNING without a struct
|
||||
let registration_id: Uuid = dl_request.get("registration_id");
|
||||
let deployment_id: String = dl_request.get("deployment_id");
|
||||
let _return_url: String = dl_request.get::<String, _>("return_url");
|
||||
let dl_data: Option<String> = dl_request.get("data");
|
||||
|
||||
// 2. Find registration
|
||||
let registration = sqlx::query_as::<_, LtiRegistration>(
|
||||
"SELECT * FROM lti_registrations WHERE id = $1",
|
||||
)
|
||||
.bind(registration_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let response_claims = common::models::LtiDeepLinkingResponseClaims {
|
||||
issuer: registration.client_id,
|
||||
subject: claims.sub.to_string(),
|
||||
audience: registration.issuer,
|
||||
expires_at: now + 3600,
|
||||
issued_at: now,
|
||||
nonce: Uuid::new_v4().to_string(),
|
||||
message_type: "LtiDeepLinkingResponse".to_string(),
|
||||
version: "1.3.0".to_string(),
|
||||
deployment_id,
|
||||
content_items: payload.items,
|
||||
data: dl_data,
|
||||
};
|
||||
|
||||
// 7. Generate JWT
|
||||
let claims = Claims {
|
||||
sub: user.id,
|
||||
role: user.role,
|
||||
org: user.organization_id,
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp(),
|
||||
course_id: None,
|
||||
token_type: Some("access".to_string()),
|
||||
};
|
||||
|
||||
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::default(),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
|
||||
let private_key = crate::jwks::get_lti_private_key();
|
||||
let response_jwt = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header {
|
||||
kid: Some("openccb-lti-key-1".to_string()),
|
||||
alg: jsonwebtoken::Algorithm::RS256,
|
||||
..Default::default()
|
||||
},
|
||||
&response_claims,
|
||||
&private_key,
|
||||
)
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// 8. Redirect to Experience app launch page
|
||||
let experience_url = std::env::var("NEXT_PUBLIC_EXPERIENCE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
|
||||
Ok(Redirect::to(&format!("{}/lti/launch?token={}&target={}", experience_url, token, urlencoding::encode(&redirect_target))))
|
||||
Ok(Json(json!({
|
||||
"jwt": response_jwt,
|
||||
"return_url": dl_request.get::<String, _>("return_url")
|
||||
})))
|
||||
}
|
||||
|
||||
use axum::Json;
|
||||
use sqlx::Row;
|
||||
|
||||
@@ -7,11 +7,16 @@ mod handlers_notes;
|
||||
mod handlers_payments;
|
||||
mod handlers_peer_review;
|
||||
mod lti;
|
||||
mod jwks;
|
||||
mod predictive;
|
||||
mod live;
|
||||
mod portfolio;
|
||||
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use axum::Json; // Added based on instruction
|
||||
use dotenvy::dotenv;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::env;
|
||||
@@ -86,6 +91,17 @@ async fn main() {
|
||||
"/courses/{id}/recommendations",
|
||||
get(handlers::get_recommendations),
|
||||
)
|
||||
.route(
|
||||
"/courses/{id}/dropout-risks",
|
||||
get(predictive::get_course_dropout_risks),
|
||||
)
|
||||
// Live Learning
|
||||
.route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting))
|
||||
.route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting))
|
||||
// Portfolio & Badges
|
||||
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
||||
.route("/my/badges", get(portfolio::get_my_badges))
|
||||
.route("/badges/award", post(portfolio::award_badge))
|
||||
.route(
|
||||
"/users/{id}/gamification",
|
||||
get(handlers::get_user_gamification),
|
||||
@@ -210,6 +226,8 @@ async fn main() {
|
||||
)
|
||||
.route("/lti/login", get(lti::lti_login_initiation))
|
||||
.route("/lti/launch", post(lti::lti_launch))
|
||||
.route("/lti/jwks", get(jwks::lti_jwks_handler))
|
||||
.route("/lti/deep-linking/response", post(lti::lti_deep_linking_response))
|
||||
.merge(protected_routes)
|
||||
.layer(cors)
|
||||
.with_state(pool);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use common::auth::Claims;
|
||||
use common::models::{Badge, UserBadge, PublicProfile};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_public_profile(
|
||||
Path(user_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<PublicProfile>, (StatusCode, String)> {
|
||||
let user = sqlx::query("SELECT id, full_name, avatar_url, bio, level, xp, is_public_profile FROM users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
||||
|
||||
let is_public: bool = user.get("is_public_profile");
|
||||
if !is_public {
|
||||
return Err((StatusCode::FORBIDDEN, "This profile is private".to_string()));
|
||||
}
|
||||
|
||||
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
|
||||
r#"
|
||||
SELECT b.* FROM badges b
|
||||
JOIN user_badges ub ON b.id = ub.badge_id
|
||||
WHERE ub.user_id = $1
|
||||
"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let completed_courses: i64 = sqlx::query("SELECT COUNT(*) FROM enrollments WHERE user_id = $1 AND progress_percentage >= 100")
|
||||
.bind(user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.get(0);
|
||||
|
||||
Ok(Json(PublicProfile {
|
||||
user_id,
|
||||
full_name: user.get("full_name"),
|
||||
avatar_url: user.get("avatar_url"),
|
||||
bio: user.get("bio"),
|
||||
badges,
|
||||
level: user.get("level"),
|
||||
xp: user.get("xp"),
|
||||
completed_courses_count: completed_courses,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_my_badges(
|
||||
claims: Claims,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<Badge>>, (StatusCode, String)> {
|
||||
let badges = sqlx::query_as::<sqlx::Postgres, Badge>(
|
||||
r#"
|
||||
SELECT b.* FROM badges b
|
||||
JOIN user_badges ub ON b.id = ub.badge_id
|
||||
WHERE ub.user_id = $1
|
||||
"#
|
||||
)
|
||||
.bind(claims.sub)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(badges))
|
||||
}
|
||||
|
||||
pub async fn award_badge(
|
||||
State(pool): State<PgPool>,
|
||||
claims: Claims,
|
||||
Json(payload): Json<UserBadge>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only admins can award badges manually".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO user_badges (user_id, badge_id, awarded_at) VALUES ($1, $2, NOW()) ON CONFLICT DO NOTHING")
|
||||
.bind(payload.user_id)
|
||||
.bind(payload.badge_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use chrono::{Utc, Duration};
|
||||
use serde_json::json;
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
use common::auth::Claims;
|
||||
use common::models::{DropoutRisk, DropoutRiskLevel, DropoutRiskReason};
|
||||
|
||||
pub async fn get_course_dropout_risks(
|
||||
Path(course_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
claims: Claims,
|
||||
) -> Result<Json<Vec<DropoutRisk>>, (StatusCode, String)> {
|
||||
if claims.role == "student" {
|
||||
return Err((StatusCode::FORBIDDEN, "Only instructors can view risk reports".to_string()));
|
||||
}
|
||||
|
||||
calculate_risks_for_course(&pool, course_id, claims.org).await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at, created_at, updated_at
|
||||
FROM dropout_risks
|
||||
WHERE course_id = $1 AND organization_id = $2
|
||||
ORDER BY score DESC
|
||||
"#,
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(claims.org)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to fetch risks: {}", e)))?;
|
||||
|
||||
let risks: Vec<DropoutRisk> = rows.into_iter().map(|row| {
|
||||
DropoutRisk {
|
||||
id: row.get("id"),
|
||||
organization_id: row.get("organization_id"),
|
||||
course_id: row.get("course_id"),
|
||||
user_id: row.get("user_id"),
|
||||
risk_level: row.get("risk_level"),
|
||||
score: row.get("score"),
|
||||
reasons: row.get("reasons"),
|
||||
last_calculated_at: row.get("last_calculated_at"),
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Json(risks))
|
||||
}
|
||||
|
||||
pub async fn calculate_risks_for_course(
|
||||
pool: &PgPool,
|
||||
course_id: Uuid,
|
||||
organization_id: Uuid,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let enrollments = sqlx::query("SELECT user_id FROM enrollments WHERE course_id = $1 AND organization_id = $2")
|
||||
.bind(course_id)
|
||||
.bind(organization_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for enrollment in enrollments {
|
||||
let user_id: Uuid = enrollment.get("user_id");
|
||||
|
||||
let avg_grade: f32 = sqlx::query("SELECT COALESCE(AVG(score), 0.0) FROM user_grades WHERE user_id = $1 AND course_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(course_id)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
.get::<f64, _>(0) as f32; // AVG returns f64 usually
|
||||
|
||||
let last_activity_count: i64 = sqlx::query("SELECT COUNT(*) FROM lesson_interactions WHERE user_id = $1 AND created_at > $2")
|
||||
.bind(user_id)
|
||||
.bind(Utc::now() - Duration::days(7))
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
.get(0);
|
||||
|
||||
let forum_posts: i64 = sqlx::query("SELECT COUNT(*) FROM discussion_posts WHERE author_id = $1 AND organization_id = $2")
|
||||
.bind(user_id)
|
||||
.bind(organization_id)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
.get(0);
|
||||
|
||||
let perf_risk = (1.0 - avg_grade).max(0.0);
|
||||
let activity_risk = (1.0 / (last_activity_count as f32 + 1.0)).min(1.0);
|
||||
let social_risk = (1.0 / (forum_posts as f32 + 1.0)).min(1.0);
|
||||
|
||||
let total_score = (perf_risk * 0.5) + (activity_risk * 0.4) + (social_risk * 0.1);
|
||||
|
||||
let risk_level = if total_score > 0.8 {
|
||||
DropoutRiskLevel::Critical
|
||||
} else if total_score > 0.5 {
|
||||
DropoutRiskLevel::High
|
||||
} else if total_score > 0.3 {
|
||||
DropoutRiskLevel::Medium
|
||||
} else {
|
||||
DropoutRiskLevel::Low
|
||||
};
|
||||
|
||||
let reasons = vec![
|
||||
DropoutRiskReason { metric: "performance".to_string(), value: avg_grade, description: format!("Grade: {:.0}%", avg_grade * 100.0) },
|
||||
DropoutRiskReason { metric: "activity".to_string(), value: last_activity_count as f32, description: format!("{} actions in last week", last_activity_count) },
|
||||
];
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO dropout_risks (organization_id, course_id, user_id, risk_level, score, reasons, last_calculated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (course_id, user_id) DO UPDATE SET
|
||||
risk_level = EXCLUDED.risk_level,
|
||||
score = EXCLUDED.score,
|
||||
reasons = EXCLUDED.reasons,
|
||||
last_calculated_at = EXCLUDED.last_calculated_at,
|
||||
updated_at = NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(organization_id)
|
||||
.bind(course_id)
|
||||
.bind(user_id)
|
||||
.bind(risk_level)
|
||||
.bind(total_score)
|
||||
.bind(json!(reasons))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user