From e4374628754f8e2dc76c4865cc6275888cd06a53 Mon Sep 17 00:00:00 2001 From: Anmol Sharma Date: Fri, 3 Apr 2026 21:06:57 +0530 Subject: [PATCH 1/2] feat: add attachment support for cohort week questions Questions and bonus questions are now objects with text and attachments fields instead of plain strings. Attachments are stored as files in assets/cohort-configs/attachments/{cohort-type}/ and served via a streaming endpoint. Config validation ensures referenced files exist on startup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../bitcoin-protocol-development/.gitkeep | 0 .../.gitkeep | 0 .../attachments/mastering-bitcoin/.gitkeep | 0 .../mastering-lightning-network/.gitkeep | 0 .../attachments/programming-bitcoin/.gitkeep | 0 migrations/1775229793448-migrations.ts | 57 +++++++++ package-lock.json | 117 ++++++++++++++++-- package.json | 2 + src/cohorts/cohorts.config.model.ts | 21 +++- src/cohorts/cohorts.config.service.ts | 28 ++++- src/cohorts/cohorts.controller.ts | 13 ++ src/cohorts/cohorts.request.dto.ts | 28 ++++- src/cohorts/cohorts.response.dto.ts | 5 +- src/cohorts/cohorts.service.ts | 79 +++++++++++- src/entities/cohort-week.entity.ts | 9 +- 15 files changed, 329 insertions(+), 30 deletions(-) create mode 100644 assets/cohort-configs/attachments/bitcoin-protocol-development/.gitkeep create mode 100644 assets/cohort-configs/attachments/learning-bitcoin-from-command-line/.gitkeep create mode 100644 assets/cohort-configs/attachments/mastering-bitcoin/.gitkeep create mode 100644 assets/cohort-configs/attachments/mastering-lightning-network/.gitkeep create mode 100644 assets/cohort-configs/attachments/programming-bitcoin/.gitkeep create mode 100644 migrations/1775229793448-migrations.ts diff --git a/assets/cohort-configs/attachments/bitcoin-protocol-development/.gitkeep b/assets/cohort-configs/attachments/bitcoin-protocol-development/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/cohort-configs/attachments/learning-bitcoin-from-command-line/.gitkeep b/assets/cohort-configs/attachments/learning-bitcoin-from-command-line/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/cohort-configs/attachments/mastering-bitcoin/.gitkeep b/assets/cohort-configs/attachments/mastering-bitcoin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/cohort-configs/attachments/mastering-lightning-network/.gitkeep b/assets/cohort-configs/attachments/mastering-lightning-network/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/cohort-configs/attachments/programming-bitcoin/.gitkeep b/assets/cohort-configs/attachments/programming-bitcoin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/1775229793448-migrations.ts b/migrations/1775229793448-migrations.ts new file mode 100644 index 0000000..0dbc8d1 --- /dev/null +++ b/migrations/1775229793448-migrations.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Migrations1775229793448 implements MigrationInterface { + name = 'Migrations1775229793448'; + + public async up(queryRunner: QueryRunner): Promise { + // Migrate questions from string[] to {text, attachments}[] + await queryRunner.query(` + UPDATE cohort_week + SET questions = ( + SELECT COALESCE(jsonb_agg(jsonb_build_object('text', elem.value #>> '{}', 'attachments', '[]'::jsonb)), '[]'::jsonb) + FROM jsonb_array_elements(questions) AS elem(value) + ) + WHERE jsonb_typeof(questions) = 'array' + AND jsonb_array_length(questions) > 0 + AND jsonb_typeof(questions -> 0) = 'string' + `); + + // Migrate bonusQuestion from string[] to {text, attachments}[] + await queryRunner.query(` + UPDATE cohort_week + SET "bonusQuestion" = ( + SELECT COALESCE(jsonb_agg(jsonb_build_object('text', elem.value #>> '{}', 'attachments', '[]'::jsonb)), '[]'::jsonb) + FROM jsonb_array_elements("bonusQuestion") AS elem(value) + ) + WHERE jsonb_typeof("bonusQuestion") = 'array' + AND jsonb_array_length("bonusQuestion") > 0 + AND jsonb_typeof("bonusQuestion" -> 0) = 'string' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Revert questions from {text, attachments}[] back to string[] + await queryRunner.query(` + UPDATE cohort_week + SET questions = ( + SELECT COALESCE(jsonb_agg(elem.value -> 'text'), '[]'::jsonb) + FROM jsonb_array_elements(questions) AS elem(value) + ) + WHERE jsonb_typeof(questions) = 'array' + AND jsonb_array_length(questions) > 0 + AND jsonb_typeof(questions -> 0) = 'object' + `); + + // Revert bonusQuestion from {text, attachments}[] back to string[] + await queryRunner.query(` + UPDATE cohort_week + SET "bonusQuestion" = ( + SELECT COALESCE(jsonb_agg(elem.value -> 'text'), '[]'::jsonb) + FROM jsonb_array_elements("bonusQuestion") AS elem(value) + ) + WHERE jsonb_typeof("bonusQuestion") = 'array' + AND jsonb_array_length("bonusQuestion") > 0 + AND jsonb_typeof("bonusQuestion" -> 0) = 'object' + `); + } +} diff --git a/package-lock.json b/package-lock.json index cdd7cb9..487a1e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "eta": "^3.5.0", "ical-generator": "^10.1.0", "js-yaml": "^4.1.0", + "mime-types": "^3.0.2", "nest-winston": "^1.10.2", "nodemailer": "^7.0.10", "pdf-lib": "^1.17.1", @@ -51,6 +52,7 @@ "@types/express": "^4.17.13", "@types/jest": "29.5.0", "@types/js-yaml": "^4.0.9", + "@types/mime-types": "^3.0.1", "@types/node": "18.15.11", "@types/secp256k1": "^4.0.6", "@types/supertest": "^2.0.11", @@ -4222,6 +4224,13 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "18.15.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz", @@ -4760,6 +4769,27 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -8128,6 +8158,27 @@ "node": ">= 6" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formidable": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", @@ -10733,22 +10784,28 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -13383,6 +13440,27 @@ "node": ">= 0.6" } }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -13933,6 +14011,29 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 6e603f2..a7167f7 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "eta": "^3.5.0", "ical-generator": "^10.1.0", "js-yaml": "^4.1.0", + "mime-types": "^3.0.2", "nest-winston": "^1.10.2", "nodemailer": "^7.0.10", "pdf-lib": "^1.17.1", @@ -70,6 +71,7 @@ "@types/express": "^4.17.13", "@types/jest": "29.5.0", "@types/js-yaml": "^4.0.9", + "@types/mime-types": "^3.0.1", "@types/node": "18.15.11", "@types/secp256k1": "^4.0.6", "@types/supertest": "^2.0.11", diff --git a/src/cohorts/cohorts.config.model.ts b/src/cohorts/cohorts.config.model.ts index 0623e1e..5401e29 100644 --- a/src/cohorts/cohorts.config.model.ts +++ b/src/cohorts/cohorts.config.model.ts @@ -5,6 +5,7 @@ import { IsBoolean, IsInt, IsNumberString, + IsOptional, IsString, Max, Min, @@ -13,17 +14,29 @@ import { } from 'class-validator'; import { Type } from 'class-transformer'; +export class QuestionConfig { + @IsString() + text!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + attachments?: string[]; +} + export class CohortWeekConfig { @IsBoolean() hasExercise!: boolean; @IsArray() - @IsString({ each: true }) - questions!: string[]; + @ValidateNested({ each: true }) + @Type(() => QuestionConfig) + questions!: QuestionConfig[]; @IsArray() - @IsString({ each: true }) - bonusQuestions!: string[]; + @ValidateNested({ each: true }) + @Type(() => QuestionConfig) + bonusQuestions!: QuestionConfig[]; } export class CohortConfig { diff --git a/src/cohorts/cohorts.config.service.ts b/src/cohorts/cohorts.config.service.ts index 24edfbd..a7762d3 100644 --- a/src/cohorts/cohorts.config.service.ts +++ b/src/cohorts/cohorts.config.service.ts @@ -1,10 +1,11 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { CohortType } from '@/common/enum'; import { join } from 'path'; -import { readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import { validateSync } from 'class-validator'; import { plainToInstance } from 'class-transformer'; -import { CohortConfig } from '@/cohorts/cohorts.config.model'; +import { CohortConfig, QuestionConfig } from '@/cohorts/cohorts.config.model'; +import { ServiceError } from '@/common/errors'; @Injectable() export class CohortsConfigService implements OnModuleInit { @@ -46,6 +47,27 @@ export class CohortsConfigService implements OnModuleInit { ); } + // Validate that all referenced attachment files exist + const attachDir = join( + configDir, + 'attachments', + type.toLowerCase().replace(/_/g, '-'), + ); + const allQuestions: QuestionConfig[] = config.weeks.flatMap((w) => [ + ...w.questions, + ...w.bonusQuestions, + ]); + for (const question of allQuestions) { + for (const attachment of question.attachments ?? []) { + const attachPath = join(attachDir, attachment); + if (!existsSync(attachPath)) { + throw new ServiceError( + `Missing attachment file for ${type}: ${attachPath}`, + ); + } + } + } + this.configs.set(type, config); this.logger.log(`Loaded config for ${type} (${fileName})`); } @@ -54,7 +76,7 @@ export class CohortsConfigService implements OnModuleInit { getConfig(type: CohortType): CohortConfig { const config = this.configs.get(type); if (!config) { - throw new Error(`No config found for cohort type: ${type}`); + throw new ServiceError(`No config found for cohort type: ${type}`); } return config; } diff --git a/src/cohorts/cohorts.controller.ts b/src/cohorts/cohorts.controller.ts index c368e47..d572a1c 100644 --- a/src/cohorts/cohorts.controller.ts +++ b/src/cohorts/cohorts.controller.ts @@ -7,6 +7,8 @@ import { Patch, Post, Query, + Res, + StreamableFile, UsePipes, ValidationPipe, } from '@nestjs/common'; @@ -16,6 +18,7 @@ import { ApiQuery, ApiTags, } from '@nestjs/swagger'; +import type { Response } from 'express'; import { CreateCohortRequestDto, JoinWaitlistRequestDto, @@ -94,6 +97,16 @@ export class CohortsController { return this.cohortsService.listMyCohorts(user, query); } + @Get('attachments/:id/:filename') + @ApiOperation({ summary: 'Stream a cohort question attachment' }) + async getAttachment( + @Param('id', new ParseUUIDPipe()) id: string, + @Param('filename') filename: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + return this.cohortsService.getAttachment(id, filename, res); + } + @Get(':id') @ApiOperation({ summary: 'Get a cohort by ID' }) async getCohort( diff --git a/src/cohorts/cohorts.request.dto.ts b/src/cohorts/cohorts.request.dto.ts index 606cebd..02b00f8 100644 --- a/src/cohorts/cohorts.request.dto.ts +++ b/src/cohorts/cohorts.request.dto.ts @@ -1,11 +1,14 @@ import { + IsArray, IsDateString, IsEnum, IsNotEmpty, IsNumberString, IsOptional, IsString, + ValidateNested, } from 'class-validator'; +import { Type } from 'class-transformer'; import { CohortType } from '@/common/enum'; export class UpdateCohortRequestDto { @@ -29,16 +32,29 @@ export class CreateCohortRequestDto { registrationDeadline!: string; } -export class UpdateCohortWeekRequestDto { +export class QuestionDto { + @IsString() + @IsNotEmpty() + text!: string; + @IsOptional() + @IsArray() @IsString({ each: true }) - @IsNotEmpty({ each: true }) - questions!: string[] | undefined; + attachments?: string[]; +} +export class UpdateCohortWeekRequestDto { @IsOptional() - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - bonusQuestion!: string[] | undefined; + @IsArray() + @ValidateNested({ each: true }) + @Type(() => QuestionDto) + questions?: QuestionDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => QuestionDto) + bonusQuestion?: QuestionDto[]; @IsOptional() @IsNumberString({ diff --git a/src/cohorts/cohorts.response.dto.ts b/src/cohorts/cohorts.response.dto.ts index b082dca..896a7a7 100644 --- a/src/cohorts/cohorts.response.dto.ts +++ b/src/cohorts/cohorts.response.dto.ts @@ -1,13 +1,14 @@ import { CohortType, CohortWeekType } from '@/common/enum'; import { Cohort } from '@/entities/cohort.entity'; +import { Question } from '@/entities/cohort-week.entity'; export class GetCohortWeekResponseDto { id!: string; week!: number; type!: CohortWeekType; hasExercise!: boolean; - questions!: string[]; - bonusQuestion!: string[]; + questions!: Question[]; + bonusQuestion!: Question[]; classroomInviteLink!: string | null; classroomAssignmentUrl!: string | null; scheduledDate!: string; diff --git a/src/cohorts/cohorts.service.ts b/src/cohorts/cohorts.service.ts index d5eb47a..6e65e1c 100644 --- a/src/cohorts/cohorts.service.ts +++ b/src/cohorts/cohorts.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + StreamableFile, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Cohort } from '@/entities/cohort.entity'; import { Repository } from 'typeorm'; @@ -31,6 +37,10 @@ import { APITaskStatus, TaskType } from '@/task-processor/task.enums'; import { MailService } from '@/mail/mail.service'; import { CohortsConfigService } from '@/cohorts/cohorts.config.service'; import { CohortCalendarService } from '@/cohort-calendar/cohort-calendar.service'; +import { createReadStream, existsSync } from 'fs'; +import { join, basename } from 'path'; +import { lookup } from 'mime-types'; +import type { Response } from 'express'; @Injectable() export class CohortsService { @@ -95,6 +105,49 @@ export class CohortsService { return GetCohortResponseDto.fromEntity(cohort); } + async getAttachment( + cohortId: string, + filename: string, + res: Response, + ): Promise { + const cohort = await this.cohortRepository.findOne({ + where: { id: cohortId }, + select: ['type'], + }); + + if (!cohort) { + throw new NotFoundException( + `Cohort with id ${cohortId} does not exist.`, + ); + } + + // Prevent path traversal + const sanitized = basename(filename); + if (sanitized !== filename || filename.includes('\0')) { + throw new BadRequestException('Invalid filename.'); + } + + const dir = cohort.type.toLowerCase().replace(/_/g, '-'); + const filePath = join( + __dirname, + '..', + 'assets', + 'cohort-configs', + 'attachments', + dir, + sanitized, + ); + + if (!existsSync(filePath)) { + throw new NotFoundException('Attachment not found.'); + } + + const contentType = lookup(filePath) || 'application/octet-stream'; + res.set({ 'Content-Type': contentType }); + + return new StreamableFile(createReadStream(filePath)); + } + private mapLatestCohortsToPublicCohortResponseDto( cohorts: Cohort[], type: CohortType, @@ -284,8 +337,16 @@ export class CohortsService { const weekConfig = config.weeks[weekNumber - 1]; week.type = CohortWeekType.GROUP_DISCUSSION; week.hasExercise = weekConfig.hasExercise; - week.questions = weekConfig.questions; - week.bonusQuestion = weekConfig.bonusQuestions; + week.questions = weekConfig.questions.map((q) => ({ + text: q.text, + attachments: q.attachments ?? [], + })); + week.bonusQuestion = weekConfig.bonusQuestions.map( + (q) => ({ + text: q.text, + attachments: q.attachments ?? [], + }), + ); } else { week.type = CohortWeekType.GRADUATION; week.hasExercise = false; @@ -428,10 +489,18 @@ export class CohortsService { } if (cohortWeekData.questions) { - cohortWeek.questions = cohortWeekData.questions; + cohortWeek.questions = cohortWeekData.questions.map((q) => ({ + text: q.text, + attachments: q.attachments ?? [], + })); } if (cohortWeekData.bonusQuestion) { - cohortWeek.bonusQuestion = cohortWeekData.bonusQuestion; + cohortWeek.bonusQuestion = cohortWeekData.bonusQuestion.map( + (q) => ({ + text: q.text, + attachments: q.attachments ?? [], + }), + ); } if (cohortWeekData.classroomAssignmentId !== undefined) { cohortWeek.classroomAssignmentId = diff --git a/src/entities/cohort-week.entity.ts b/src/entities/cohort-week.entity.ts index 8f8ee16..123f17f 100644 --- a/src/entities/cohort-week.entity.ts +++ b/src/entities/cohort-week.entity.ts @@ -14,6 +14,11 @@ import { Attendance } from '@/entities/attendance.entity'; import { BaseEntity } from '@/entities/base.entity'; import { CohortWeekType } from '@/common/enum'; +export interface Question { + text: string; + attachments: string[]; +} + @Entity() @Unique(['cohort', 'week']) @Check(`NOT "hasExercise" OR "type" = 'GROUP_DISCUSSION'`) @@ -31,10 +36,10 @@ export class CohortWeek extends BaseEntity { hasExercise!: boolean; @Column('jsonb', { default: [] }) - questions!: string[]; + questions!: Question[]; @Column('jsonb', { default: [] }) - bonusQuestion!: string[]; + bonusQuestion!: Question[]; @Column('text', { nullable: true }) classroomInviteLink!: string | null; From 9ef64863ead41e7ed99337f29ddba12ba52143ad Mon Sep 17 00:00:00 2001 From: Anmol Sharma Date: Fri, 3 Apr 2026 21:33:21 +0530 Subject: [PATCH 2/2] feat: add endpoint to sync cohort questions from config Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cohorts/cohorts.controller.ts | 11 +++++++++++ src/cohorts/cohorts.service.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/cohorts/cohorts.controller.ts b/src/cohorts/cohorts.controller.ts index d572a1c..1d852b8 100644 --- a/src/cohorts/cohorts.controller.ts +++ b/src/cohorts/cohorts.controller.ts @@ -122,6 +122,17 @@ export class CohortsController { await this.cohortsService.createCohort(body); } + @Post(':cohortId/sync-questions') + @ApiOperation({ + summary: 'Sync cohort week questions from config file', + }) + @Roles(UserRole.TEACHING_ASSISTANT, UserRole.ADMIN) + async syncQuestionsFromConfig( + @Param('cohortId', new ParseUUIDPipe()) cohortId: string, + ): Promise { + await this.cohortsService.syncQuestionsFromConfig(cohortId); + } + @Patch(':cohortId') @ApiOperation({ summary: 'Update a cohort' }) @Roles(UserRole.TEACHING_ASSISTANT, UserRole.ADMIN) diff --git a/src/cohorts/cohorts.service.ts b/src/cohorts/cohorts.service.ts index 6e65e1c..d5d70cb 100644 --- a/src/cohorts/cohorts.service.ts +++ b/src/cohorts/cohorts.service.ts @@ -569,6 +569,39 @@ export class CohortsService { return task; } + async syncQuestionsFromConfig(cohortId: string): Promise { + const cohort = await this.cohortRepository.findOne({ + where: { id: cohortId }, + relations: { weeks: true }, + }); + + if (!cohort) { + throw new BadRequestException( + `Cohort with id ${cohortId} does not exist.`, + ); + } + + const config = this.cohortConfigService.getConfig(cohort.type); + + for (const week of cohort.weeks) { + if (week.type !== CohortWeekType.GROUP_DISCUSSION) continue; + + const weekConfig = config.weeks[week.week - 1]; + if (!weekConfig) continue; + + week.questions = weekConfig.questions.map((q) => ({ + text: q.text, + attachments: q.attachments ?? [], + })); + week.bonusQuestion = weekConfig.bonusQuestions.map((q) => ({ + text: q.text, + attachments: q.attachments ?? [], + })); + } + + await this.cohortWeekRepository.save(cohort.weeks); + } + async assignDiscordRole(userId: string, cohortType: CohortType) { const user = await this.userRepository.findOneOrFail({ where: { id: userId },