diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index b1e0840..e9fc261 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -880,3 +880,33 @@ pub async fn get_course_outline( modules: modules_with_lessons, })) } + +pub async fn update_module( + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + let title = payload.get("title").and_then(|t| t.as_str()); + let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32); + + let updated_module = sqlx::query_as::<_, Module>( + "UPDATE modules + SET title = COALESCE($1, title), + position = COALESCE($2, position) + WHERE id = $3 RETURNING *" + ) + .bind(title) + .bind(position) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Update module failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + log_action(&pool, claims.sub, "UPDATE", "Module", id, json!(payload)).await; + + Ok(Json(updated_module)) +} diff --git a/services/cms-service/src/handlers_update_module.rs b/services/cms-service/src/handlers_update_module.rs new file mode 100644 index 0000000..313f60a --- /dev/null +++ b/services/cms-service/src/handlers_update_module.rs @@ -0,0 +1,30 @@ + +pub async fn update_module( + claims: common::auth::Claims, + State(pool): State, + Path(id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + let title = payload.get("title").and_then(|t| t.as_str()); + let position = payload.get("position").and_then(|v| v.as_i64()).map(|v| v as i32); + + let updated_module = sqlx::query_as::<_, Module>( + "UPDATE modules + SET title = COALESCE($1, title), + position = COALESCE($2, position) + WHERE id = $3 RETURNING *" + ) + .bind(title) + .bind(position) + .bind(id) + .fetch_one(&pool) + .await + .map_err(|e| { + tracing::error!("Update module failed: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + log_action(&pool, claims.sub, "UPDATE", "Module", id, json!(payload)).await; + + Ok(Json(updated_module)) +} diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index f086125..29a51be 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -42,6 +42,7 @@ async fn main() { .route("/courses/{id}/outline", get(handlers::get_course_outline)) .route("/courses/{id}/analytics", get(handlers::get_course_analytics)) .route("/modules", get(handlers::get_modules).post(handlers::create_module)) + .route("/modules/{id}", axum::routing::put(handlers::update_module)) .route("/lessons", get(handlers::get_lessons).post(handlers::create_lesson)) .route("/lessons/{id}", get(handlers::get_lesson).put(handlers::update_lesson)) .route("/lessons/{id}/transcribe", post(handlers::process_transcription)) diff --git a/web/studio/package-lock.json b/web/studio/package-lock.json index 506c70f..9a0f6bf 100644 --- a/web/studio/package-lock.json +++ b/web/studio/package-lock.json @@ -8,6 +8,7 @@ "name": "studio", "version": "0.1.0", "dependencies": { + "@hello-pangea/dnd": "^18.0.1", "clsx": "^2.1.1", "framer-motion": "^11.2.10", "lucide-react": "^0.395.0", @@ -39,6 +40,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -129,6 +138,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hello-pangea/dnd": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", + "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==", + "dependencies": { + "@babel/runtime": "^7.26.7", + "css-box-model": "^1.2.1", + "raf-schd": "^4.0.3", + "react-redux": "^9.2.0", + "redux": "^5.0.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -513,13 +538,13 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -534,6 +559,11 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", @@ -1593,6 +1623,14 @@ "node": ">= 8" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1609,7 +1647,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true + "devOptional": true }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -4299,6 +4337,11 @@ } ] }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -4328,6 +4371,28 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4349,6 +4414,11 @@ "node": ">=8.10.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5116,6 +5186,11 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5386,6 +5461,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/web/studio/package.json b/web/studio/package.json index bb91593..e318fb2 100644 --- a/web/studio/package.json +++ b/web/studio/package.json @@ -9,22 +9,23 @@ "lint": "next lint" }, "dependencies": { + "@hello-pangea/dnd": "^18.0.1", + "clsx": "^2.1.1", + "framer-motion": "^11.2.10", + "lucide-react": "^0.395.0", + "next": "14.2.21", "react": "^18", "react-dom": "^18", - "next": "14.2.21", - "lucide-react": "^0.395.0", - "framer-motion": "^11.2.10", - "clsx": "^2.1.1", "tailwind-merge": "^2.3.0" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.21", "postcss": "^8", "tailwindcss": "^3.4.1", - "eslint": "^8", - "eslint-config-next": "14.2.21" + "typescript": "^5" } -} \ No newline at end of file +} diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 4062a30..9738fc5 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -167,6 +167,7 @@ export const cmsApi = { // Modules & Lessons createModule: (course_id: string, title: string, position: number): Promise => apiFetch('/modules', { method: 'POST', body: JSON.stringify({ course_id, title, position }) }), + updateModule: (id: string, payload: Partial): Promise => apiFetch(`/modules/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), createLesson: (module_id: string, title: string, content_type: string, position: number): Promise => apiFetch('/lessons', { method: 'POST', body: JSON.stringify({ module_id, title, content_type, position }) }), getLesson: (id: string): Promise => apiFetch(`/lessons/${id}`), updateLesson: (id: string, payload: Partial): Promise => apiFetch(`/lessons/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),