Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/frontend/app/(dashboard)/components/DeleteCourse.tsx
Original file line number Diff line number Diff line change
@@ -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<DeleteCourseProps> = ({
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 (
<div className="flex flex-col items-center md:flex-row">
<div className="mb-2 w-full md:mr-4 md:w-5/6 md:text-left">
<strong className="text-red-600">Permanently Delete Course</strong>
<div className="mb-0">
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.
</div>
</div>
<Popconfirm
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we wanted to get really fancy with it we could have them re-type out the name of the course or something, but meh

title="Are you sure you want to permanently delete this course?"
description="This action cannot be undone."
onConfirm={handleDelete}
okText="Yes, Delete"
cancelText="Cancel"
okButtonProps={{ danger: true }}
>
<Button danger type="primary" className="w-full md:w-auto">
Delete Course
</Button>
</Popconfirm>
</div>
)
}

export default DeleteCourse
12 changes: 12 additions & 0 deletions packages/frontend/app/(dashboard)/components/EditCourse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { API } from '@/app/api'
import {
GetOrganizationResponse,
OrganizationCourseResponse,
OrganizationRole,
Role,
User,
} from '@koh/common'
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'
Expand Down Expand Up @@ -176,6 +178,16 @@ const EditCourse: React.FC<EditCourseProps> = ({
organization={organization}
fetchCourseData={fetchCourseData}
/>
{userInfo.organization?.organizationRole ===
OrganizationRole.ADMIN && (
<>
<hr className="my-4" />
<DeleteCourse
courseData={courseData}
organization={organization}
/>
</>
)}
</Card>
</div>
</>
Expand Down
14 changes: 11 additions & 3 deletions packages/frontend/app/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ToolUsageExportData[]> => {
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,
)
},
}
Expand Down Expand Up @@ -1215,6 +1215,14 @@ export class APIClient {
'PATCH',
`/api/v1/organization/${organizationId}/update_course_access/${courseId}`,
),
deleteCourse: async (
organizationId: number,
courseId: number,
): Promise<void> =>
this.req(
'DELETE',
`/api/v1/organization/${organizationId}/delete_course/${courseId}`,
),
updateAccess: async (
organizationId: number,
userId: number,
Expand Down
160 changes: 160 additions & 0 deletions packages/server/migration/1769000000000-add-course-delete-cascade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddCourseDeleteCascade1769000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
//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<void> {
// 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`,
);
}
}
24 changes: 24 additions & 0 deletions packages/server/src/organization/organization.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in this instance, I would probably want to add some tests for this. Stuff like

  • Make sure only org admins can call the endpoint
  • Try making a course with a bunch of random connected entities (queues, events, user_course, etc.) and then call this endpoint to make sure that all of those entities are deleted, but it probably doesn't need to be too fancy. In an ideal world, I would also want some way of making sure that no unintended database entities were affected (for example, if the user entities were deleted due to some ON DELETE CASCADE messup in another entity), but that would require a fair bit of work (plus there's a LOT of other tests should also check this).

@Res() res: Response,
@Param('oid', ParseIntPipe) oid: number,
@Param('cid', ParseIntPipe) cid: number,
): Promise<Response<void>> {
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)
Expand Down
43 changes: 43 additions & 0 deletions packages/server/test/organization.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down