diff --git a/packages/frontend/app/(dashboard)/components/DeleteCourse.tsx b/packages/frontend/app/(dashboard)/components/DeleteCourse.tsx new file mode 100644 index 000000000..d163db0ee --- /dev/null +++ b/packages/frontend/app/(dashboard)/components/DeleteCourse.tsx @@ -0,0 +1,60 @@ +import { API } from '@/app/api' +import { getErrorMessage } from '@/app/utils/generalUtils' +import { + GetOrganizationResponse, + OrganizationCourseResponse, +} from '@koh/common' +import { Button, message, Popconfirm } from 'antd' +import { useRouter } from 'next/navigation' + +type DeleteCourseProps = { + courseData: OrganizationCourseResponse + organization: GetOrganizationResponse +} + +const DeleteCourse: React.FC = ({ + courseData, + organization, +}) => { + const router = useRouter() + + const handleDelete = async () => { + await API.organizations + .deleteCourse(organization.id, Number(courseData.courseId)) + .then(() => { + message.success('Course deleted') + router.push('/courses') + }) + .catch((error) => { + const errorMessage = getErrorMessage(error) + message.error(errorMessage) + }) + } + + return ( +
+
+ Permanently Delete Course +
+ This will permanently delete the course and all associated data + (queues, questions, chatbot data, etc). This cannot be undone. Only + use this if you accidentally created the course. +
+
+ + + +
+ ) +} + +export default DeleteCourse diff --git a/packages/frontend/app/(dashboard)/components/EditCourse.tsx b/packages/frontend/app/(dashboard)/components/EditCourse.tsx index ffa396cba..b1a64afea 100644 --- a/packages/frontend/app/(dashboard)/components/EditCourse.tsx +++ b/packages/frontend/app/(dashboard)/components/EditCourse.tsx @@ -4,6 +4,7 @@ import { API } from '@/app/api' import { GetOrganizationResponse, OrganizationCourseResponse, + OrganizationRole, Role, User, } from '@koh/common' @@ -11,6 +12,7 @@ import { Card, message, Tooltip } from 'antd' import { useEffect, useState } from 'react' import EditCourseForm from './EditCourseForm' import ArchiveCourse from './ArchiveCourse' +import DeleteCourse from './DeleteCourse' import { useRouter } from 'next/navigation' import CourseInviteCode from './CourseInviteCode' import CourseFeaturesForm from './CourseFeaturesForm' @@ -176,6 +178,16 @@ const EditCourse: React.FC = ({ organization={organization} fetchCourseData={fetchCourseData} /> + {userInfo.organization?.organizationRole === + OrganizationRole.ADMIN && ( + <> +
+ + + )} diff --git a/packages/frontend/app/api/index.ts b/packages/frontend/app/api/index.ts index 894fd8459..fe9a7241e 100644 --- a/packages/frontend/app/api/index.ts +++ b/packages/frontend/app/api/index.ts @@ -720,19 +720,19 @@ export class APIClient { includeQueueQuestions: boolean = true, includeAnytimeQuestions: boolean = true, includeChatbotInteractions: boolean = true, - groupBy: 'day' | 'week' = 'week' + groupBy: 'day' | 'week' = 'week', ): Promise => { const queryParams = new URLSearchParams({ includeQueueQuestions: includeQueueQuestions.toString(), includeAnytimeQuestions: includeAnytimeQuestions.toString(), includeChatbotInteractions: includeChatbotInteractions.toString(), - groupBy + groupBy, }) return this.req( 'GET', `/api/v1/courses/${courseId}/export-tool-usage?${queryParams.toString()}`, - undefined + undefined, ) }, } @@ -1215,6 +1215,14 @@ export class APIClient { 'PATCH', `/api/v1/organization/${organizationId}/update_course_access/${courseId}`, ), + deleteCourse: async ( + organizationId: number, + courseId: number, + ): Promise => + this.req( + 'DELETE', + `/api/v1/organization/${organizationId}/delete_course/${courseId}`, + ), updateAccess: async ( organizationId: number, userId: number, diff --git a/packages/server/migration/1769000000000-add-course-delete-cascade.ts b/packages/server/migration/1769000000000-add-course-delete-cascade.ts new file mode 100644 index 000000000..9e28feb2b --- /dev/null +++ b/packages/server/migration/1769000000000-add-course-delete-cascade.ts @@ -0,0 +1,160 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCourseDeleteCascade1769000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + //chaging all connected tables to course_model with ON DELETE NO ACTION to ON DELETE CASCADE so that when a course is deleted, all related records in these tables will also get deleted. + + // alert_model + await queryRunner.query( + `ALTER TABLE "alert_model" DROP CONSTRAINT "FK_71566c4e2836bea0d62bb7b4db2"`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ADD CONSTRAINT "FK_71566c4e2836bea0d62bb7b4db2" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // async_question_model + await queryRunner.query( + `ALTER TABLE "async_question_model" DROP CONSTRAINT "FK_faac87edafc4297437ed4a12d0e"`, + ); + await queryRunner.query( + `ALTER TABLE "async_question_model" ADD CONSTRAINT "FK_faac87edafc4297437ed4a12d0e" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // calendar_model + await queryRunner.query( + `ALTER TABLE "calendar_model" DROP CONSTRAINT "FK_b47c12b782d3e463acdf841bbf7"`, + ); + await queryRunner.query( + `ALTER TABLE "calendar_model" ADD CONSTRAINT "FK_b47c12b782d3e463acdf841bbf7" FOREIGN KEY ("course") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // chatbot_interactions_model + await queryRunner.query( + `ALTER TABLE "chatbot_interactions_model" DROP CONSTRAINT "FK_7df3546203b677c555f27974c25"`, + ); + await queryRunner.query( + `ALTER TABLE "chatbot_interactions_model" ADD CONSTRAINT "FK_7df3546203b677c555f27974c25" FOREIGN KEY ("course") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // course_chatbot_settings_model + await queryRunner.query( + `ALTER TABLE "course_chatbot_settings_model" DROP CONSTRAINT "FK_47403ceb85db02238d9c63bd73f"`, + ); + await queryRunner.query( + `ALTER TABLE "course_chatbot_settings_model" ADD CONSTRAINT "FK_47403ceb85db02238d9c63bd73f" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // event_model + await queryRunner.query( + `ALTER TABLE "event_model" DROP CONSTRAINT "FK_4b2c20ac04a24393fff2d974024"`, + ); + await queryRunner.query( + `ALTER TABLE "event_model" ADD CONSTRAINT "FK_4b2c20ac04a24393fff2d974024" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // lms_course_integration_model + await queryRunner.query( + `ALTER TABLE "lms_course_integration_model" DROP CONSTRAINT "FK_594c79fce72d04560c3a4465ea6"`, + ); + await queryRunner.query( + `ALTER TABLE "lms_course_integration_model" ADD CONSTRAINT "FK_594c79fce72d04560c3a4465ea6" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // organization_course_model + await queryRunner.query( + `ALTER TABLE "organization_course_model" DROP CONSTRAINT "FK_4fef22be04e7b58e8728a24b207"`, + ); + await queryRunner.query( + `ALTER TABLE "organization_course_model" ADD CONSTRAINT "FK_4fef22be04e7b58e8728a24b207" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // queue_model + await queryRunner.query( + `ALTER TABLE "queue_model" DROP CONSTRAINT "FK_a35e40a16b61a6e191ad097ccdc"`, + ); + await queryRunner.query( + `ALTER TABLE "queue_model" ADD CONSTRAINT "FK_a35e40a16b61a6e191ad097ccdc" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + + // user_course_model + await queryRunner.query( + `ALTER TABLE "user_course_model" DROP CONSTRAINT "FK_3f38d8a85115b61789f02fc5c3b"`, + ); + await queryRunner.query( + `ALTER TABLE "user_course_model" ADD CONSTRAINT "FK_3f38d8a85115b61789f02fc5c3b" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // undo all changes (back to NO ACTION) + await queryRunner.query( + `ALTER TABLE "alert_model" DROP CONSTRAINT "FK_71566c4e2836bea0d62bb7b4db2"`, + ); + await queryRunner.query( + `ALTER TABLE "alert_model" ADD CONSTRAINT "FK_71566c4e2836bea0d62bb7b4db2" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "async_question_model" DROP CONSTRAINT "FK_faac87edafc4297437ed4a12d0e"`, + ); + await queryRunner.query( + `ALTER TABLE "async_question_model" ADD CONSTRAINT "FK_faac87edafc4297437ed4a12d0e" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "calendar_model" DROP CONSTRAINT "FK_b47c12b782d3e463acdf841bbf7"`, + ); + await queryRunner.query( + `ALTER TABLE "calendar_model" ADD CONSTRAINT "FK_b47c12b782d3e463acdf841bbf7" FOREIGN KEY ("course") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "chatbot_interactions_model" DROP CONSTRAINT "FK_7df3546203b677c555f27974c25"`, + ); + await queryRunner.query( + `ALTER TABLE "chatbot_interactions_model" ADD CONSTRAINT "FK_7df3546203b677c555f27974c25" FOREIGN KEY ("course") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "course_chatbot_settings_model" DROP CONSTRAINT "FK_47403ceb85db02238d9c63bd73f"`, + ); + await queryRunner.query( + `ALTER TABLE "course_chatbot_settings_model" ADD CONSTRAINT "FK_47403ceb85db02238d9c63bd73f" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "event_model" DROP CONSTRAINT "FK_4b2c20ac04a24393fff2d974024"`, + ); + await queryRunner.query( + `ALTER TABLE "event_model" ADD CONSTRAINT "FK_4b2c20ac04a24393fff2d974024" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "lms_course_integration_model" DROP CONSTRAINT "FK_594c79fce72d04560c3a4465ea6"`, + ); + await queryRunner.query( + `ALTER TABLE "lms_course_integration_model" ADD CONSTRAINT "FK_594c79fce72d04560c3a4465ea6" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "organization_course_model" DROP CONSTRAINT "FK_4fef22be04e7b58e8728a24b207"`, + ); + await queryRunner.query( + `ALTER TABLE "organization_course_model" ADD CONSTRAINT "FK_4fef22be04e7b58e8728a24b207" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "queue_model" DROP CONSTRAINT "FK_a35e40a16b61a6e191ad097ccdc"`, + ); + await queryRunner.query( + `ALTER TABLE "queue_model" ADD CONSTRAINT "FK_a35e40a16b61a6e191ad097ccdc" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + + await queryRunner.query( + `ALTER TABLE "user_course_model" DROP CONSTRAINT "FK_3f38d8a85115b61789f02fc5c3b"`, + ); + await queryRunner.query( + `ALTER TABLE "user_course_model" ADD CONSTRAINT "FK_3f38d8a85115b61789f02fc5c3b" FOREIGN KEY ("courseId") REFERENCES "course_model"("id") ON DELETE NO ACTION`, + ); + } +} diff --git a/packages/server/src/organization/organization.controller.ts b/packages/server/src/organization/organization.controller.ts index 28d7e0eff..0e987626a 100644 --- a/packages/server/src/organization/organization.controller.ts +++ b/packages/server/src/organization/organization.controller.ts @@ -641,6 +641,30 @@ export class OrganizationController { }); } + // For permanently deleting a course + @Delete(':oid/delete_course/:cid') + @UseGuards(JwtAuthGuard, OrganizationRolesGuard, EmailVerifiedGuard) + @OrgRoles(OrganizationRole.ADMIN) + async deleteCourse( + @Res() res: Response, + @Param('oid', ParseIntPipe) oid: number, + @Param('cid', ParseIntPipe) cid: number, + ): Promise> { + const course = await CourseModel.findOne({ where: { id: cid } }); + + if (!course) { + return res.status(HttpStatus.NOT_FOUND).send({ + message: ERROR_MESSAGES.courseController.courseNotFound, + }); + } + + await course.remove(); + + return res.status(HttpStatus.OK).send({ + message: 'Course deleted successfully', + }); + } + @Get(':oid/get_course/:cid') @UseGuards(JwtAuthGuard, EmailVerifiedGuard, OrgOrCourseRolesGuard) @CourseRoles(Role.PROFESSOR, Role.TA) diff --git a/packages/server/test/organization.integration.ts b/packages/server/test/organization.integration.ts index 6bd939216..fbec5de31 100644 --- a/packages/server/test/organization.integration.ts +++ b/packages/server/test/organization.integration.ts @@ -2799,6 +2799,49 @@ describe('Organization Integration', () => { }); }); + describe('DELETE /organization/:oid/delete_course/:cid', () => { + it('should return 403 when user is not an org admin', async () => { + const user = await UserFactory.create(); + const organization = await OrganizationFactory.create(); + const course = await CourseFactory.create(); + + await OrganizationUserModel.create({ + userId: user.id, + organizationId: organization.id, + role: OrganizationRole.PROFESSOR, + }).save(); + + const response = await supertest({ userId: user.id }).delete( + `/organization/${organization.id}/delete_course/${course.id}`, + ); + + expect(response.status).toBe(403); + }); + + it('should return 200 and delete course when user is an org admin', async () => { + const user = await UserFactory.create(); + const organization = await OrganizationFactory.create(); + const course = await CourseFactory.create(); + + await OrganizationUserModel.create({ + userId: user.id, + organizationId: organization.id, + role: OrganizationRole.ADMIN, + }).save(); + + const response = await supertest({ userId: user.id }).delete( + `/organization/${organization.id}/delete_course/${course.id}`, + ); + + expect(response.status).toBe(200); + + const deletedCourse = await CourseModel.findOne({ + where: { id: course.id }, + }); + expect(deletedCourse).toBeNull(); + }); + }); + describe('POST /organization/:oid/upload_logo', () => { it('should return 401 when user is not logged in', async () => { const res = await supertest().post('/organization/1/upload_logo');