From 82f9eefd61563f4765554d25bd396e8ef4a1511d Mon Sep 17 00:00:00 2001 From: Akshat-Kalra Date: Thu, 25 Sep 2025 10:27:19 -0700 Subject: [PATCH 1/9] small fix for quizzes; initial fetch without selecting resource wasn't showing up --- .../src/lmsIntegration/lmsIntegration.controller.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/server/src/lmsIntegration/lmsIntegration.controller.ts b/packages/server/src/lmsIntegration/lmsIntegration.controller.ts index a93b3eb2d..923b98616 100644 --- a/packages/server/src/lmsIntegration/lmsIntegration.controller.ts +++ b/packages/server/src/lmsIntegration/lmsIntegration.controller.ts @@ -510,14 +510,6 @@ export class LMSIntegrationController { ); } - const selectedResources: LMSResourceType[] = - integration.selectedResourceTypes; - if (!selectedResources.includes(LMSResourceType.QUIZZES)) { - throw new BadRequestException( - ERROR_MESSAGES.lmsController.resourceDisabled, - ); - } - return await this.integrationService.getItems(courseId, LMSGet.Quizzes); } From 75dbfe3e57129319ad267d2c51e7d23c36f8e1cc Mon Sep 17 00:00:00 2001 From: Akshat-Kalra Date: Thu, 25 Sep 2025 15:57:01 -0700 Subject: [PATCH 2/9] mostly made data model changes; added types; wrote some code for module fetching. --- packages/common/index.ts | 17 +++++ .../lmsCourseIntegration.entity.ts | 3 + .../lmsIntegration/lmsIntegration.adapter.ts | 75 +++++++++++++++++++ .../lmsIntegration/lmsIntegration.service.ts | 1 + .../src/lmsIntegration/lmsPage.entity.ts | 3 + 5 files changed, 99 insertions(+) diff --git a/packages/common/index.ts b/packages/common/index.ts index 319b935a5..23ee4f515 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1790,6 +1790,7 @@ export type LMSPage = { syncEnabled?: boolean modified?: Date uploaded?: Date + isModuleLinked?: boolean } export type LMSFile = { @@ -1803,6 +1804,22 @@ export type LMSFile = { uploaded?: Date } +export type LMSModule = { + id: number + name: string + items_url?: string + items?: LMSModuleItem[] +} + +export type LMSModuleItem = { + id: number + title: string + type: string + content_id?: number + html_url?: string + page_url?: string +} + export enum LMSQuizAccessLevel { LOGISTICS_ONLY = 'logistics_only', LOGISTICS_AND_QUESTIONS = 'logistics_and_questions', diff --git a/packages/server/src/lmsIntegration/lmsCourseIntegration.entity.ts b/packages/server/src/lmsIntegration/lmsCourseIntegration.entity.ts index 76c33d916..bd05898f8 100644 --- a/packages/server/src/lmsIntegration/lmsCourseIntegration.entity.ts +++ b/packages/server/src/lmsIntegration/lmsCourseIntegration.entity.ts @@ -69,6 +69,9 @@ export class LMSCourseIntegrationModel extends BaseEntity { @OneToMany((type) => LMSPageModel, (page) => page.course) pages: LMSPageModel[]; + @Column({ type: 'boolean', default: false }) + moduleLinkedPagesOnly: boolean; + @OneToMany((type) => LMSFileModel, (file) => file.course) files: LMSFileModel[]; diff --git a/packages/server/src/lmsIntegration/lmsIntegration.adapter.ts b/packages/server/src/lmsIntegration/lmsIntegration.adapter.ts index 3896652f7..6d488bfd7 100644 --- a/packages/server/src/lmsIntegration/lmsIntegration.adapter.ts +++ b/packages/server/src/lmsIntegration/lmsIntegration.adapter.ts @@ -7,6 +7,8 @@ import { LMSCourseAPIResponse, LMSFile, LMSIntegrationPlatform, + LMSModule, + LMSModuleItem, LMSPage, LMSQuiz, } from '@koh/common'; @@ -83,6 +85,13 @@ export abstract class AbstractLMSAdapter { return null; } + async getModules(): Promise<{ + status: LMSApiResponseStatus; + modules: LMSModule[]; + }> { + return null; + } + async getFiles(): Promise<{ status: LMSApiResponseStatus; files: LMSFile[]; @@ -389,6 +398,10 @@ class CanvasLMSAdapter extends ImplementedLMSAdapter { if (status != LMSApiResponseStatus.Success) return { status, pages: [] }; + const moduleLinkedPageUrls = await this.getModuleLinkedPageUrls(); + + console.log(moduleLinkedPageUrls); + const pages: LMSPage[] = []; // Individual page calls will now be cached by the Get() method @@ -405,6 +418,7 @@ class CanvasLMSAdapter extends ImplementedLMSAdapter { url: page.url, frontPage: page.front_page, modified: new Date(pageResult.data.updated_at), + isModuleLinked: moduleLinkedPageUrls.includes(page.url), }); } } @@ -422,6 +436,67 @@ class CanvasLMSAdapter extends ImplementedLMSAdapter { return result; } + private async getModuleLinkedPageUrls(): Promise { + const modulesResult = await this.getModules(); + if (modulesResult.status !== LMSApiResponseStatus.Success) return []; + + const pageUrls: string[] = []; + + for (const module of modulesResult.modules) { + const itemsResult = await this.getModuleItems(module.id); + if (itemsResult.status === LMSApiResponseStatus.Success) { + const modulePageUrls = itemsResult.items + .filter((item) => item.type === 'Page') + .map((item) => item.page_url) + .filter((url) => url); + + pageUrls.push(...modulePageUrls); + } + } + + return [...new Set(pageUrls)]; + } + + async getModules(): Promise<{ + status: LMSApiResponseStatus; + modules: LMSModule[]; + }> { + const { status, data } = await this.GetPaginated( + `courses/${this.integration.apiCourseId}/modules`, + ); + + if (status != LMSApiResponseStatus.Success) return { status, modules: [] }; + + const modules: LMSModule[] = data.map((module: any) => { + return { + id: module.id, + name: module.name, + items_url: module.items_url, + } as LMSModule; + }); + + return { + status: LMSApiResponseStatus.Success, + modules, + }; + } + + async getModuleItems(moduleId: number): Promise<{ + status: LMSApiResponseStatus; + items: LMSModuleItem[]; + }> { + const { status, data } = await this.GetPaginated( + `courses/${this.integration.apiCourseId}/modules/${moduleId}/items`, + ); + + if (status != LMSApiResponseStatus.Success) return { status, items: [] }; + + return { + status: LMSApiResponseStatus.Success, + items: data, + }; + } + async getFiles(): Promise<{ status: LMSApiResponseStatus; files: LMSFile[]; diff --git a/packages/server/src/lmsIntegration/lmsIntegration.service.ts b/packages/server/src/lmsIntegration/lmsIntegration.service.ts index d25fd1cdc..22a1ebaf4 100644 --- a/packages/server/src/lmsIntegration/lmsIntegration.service.ts +++ b/packages/server/src/lmsIntegration/lmsIntegration.service.ts @@ -330,6 +330,7 @@ export class LMSIntegrationService { modified: pages[idx].modified ?? found.modified, uploaded: found.uploaded, syncEnabled: found.syncEnabled, + isModuleLinked: found.isModuleLinked, }; } } diff --git a/packages/server/src/lmsIntegration/lmsPage.entity.ts b/packages/server/src/lmsIntegration/lmsPage.entity.ts index 375de7ad2..178a7c14c 100644 --- a/packages/server/src/lmsIntegration/lmsPage.entity.ts +++ b/packages/server/src/lmsIntegration/lmsPage.entity.ts @@ -47,6 +47,9 @@ export class LMSPageModel extends BaseEntity { @Column({ type: 'boolean', default: true }) syncEnabled: boolean; + @Column({ type: 'boolean', default: false }) + isModuleLinked: boolean; + @ManyToOne( (type) => LMSCourseIntegrationModel, (integration) => integration.pages, From fa5a73f96e5aa83a71a3a7e85cc19a52b7667785 Mon Sep 17 00:00:00 2001 From: Akshat-Kalra Date: Thu, 25 Sep 2025 16:19:57 -0700 Subject: [PATCH 3/9] Add module-linked pages setting to LMS integration. Works! But have to test more --- packages/common/index.ts | 1 + .../settings/lms_integrations/page.tsx | 48 ++++++++++++++++++- packages/frontend/app/api/index.ts | 10 ++++ .../lmsIntegration.controller.ts | 23 ++++++++- .../lmsIntegration/lmsIntegration.service.ts | 6 +++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 23ee4f515..7952796a0 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -1753,6 +1753,7 @@ export class LMSCourseIntegrationPartial { lmsSynchronize!: boolean isExpired!: boolean selectedResourceTypes?: LMSResourceType[] + moduleLinkedPagesOnly?: boolean } export type LMSCourseAPIResponse = { diff --git a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/lms_integrations/page.tsx b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/lms_integrations/page.tsx index e05bb8361..3b37eee31 100644 --- a/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/lms_integrations/page.tsx +++ b/packages/frontend/app/(dashboard)/course/[cid]/(settings)/settings/lms_integrations/page.tsx @@ -127,12 +127,17 @@ export default function CourseLMSIntegrationPage(props: { const [delModalOpen, setDelModalOpen] = useState(false) const [isTesting, setIsTesting] = useState(false) const [selectedResources, setSelectedResources] = useState([]) + const [moduleLinkedPagesOnly, setModuleLinkedPagesOnly] = + useState(false) useEffect(() => { if (integration?.selectedResourceTypes) { setSelectedResources(integration.selectedResourceTypes) } - }, [integration?.selectedResourceTypes]) + if (integration?.moduleLinkedPagesOnly !== undefined) { + setModuleLinkedPagesOnly(integration.moduleLinkedPagesOnly) + } + }, [integration?.selectedResourceTypes, integration?.moduleLinkedPagesOnly]) const onSelectedResourcesChange: GetProp< typeof Checkbox.Group, @@ -299,6 +304,23 @@ export default function CourseLMSIntegrationPage(props: { } } + const updateModuleLinkedPagesOnly = async () => { + if (!integration) return + + try { + await API.lmsIntegration.updateModuleLinkedPagesOnly( + courseId, + moduleLinkedPagesOnly, + ) + message.success('Module pages setting updated!') + // No need to refresh - the toggle state is already updated locally + } catch (error) { + message.error(getErrorMessage(error)) + // Revert the toggle if the API call failed + setModuleLinkedPagesOnly(!moduleLinkedPagesOnly) + } + } + const clearDocuments = async () => { if (integration == undefined) { message.error('No integration was specified') @@ -847,6 +869,30 @@ export default function CourseLMSIntegrationPage(props: { + + {/* Module-linked pages toggle */} + {selectedResources.includes('pages') && ( +
+ { + setModuleLinkedPagesOnly(e.target.checked) + // Auto-update when changed + setTimeout( + () => updateModuleLinkedPagesOnly(), + 100, + ) + }} + > + + + Module-linked pages only + + + +
+ )} +