Skip to content
Merged
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
Empty file.
Empty file.
Empty file.
Empty file.
57 changes: 57 additions & 0 deletions migrations/1775229793448-migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class Migrations1775229793448 implements MigrationInterface {
name = 'Migrations1775229793448';

public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
// 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'
`);
}
}
117 changes: 109 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
21 changes: 17 additions & 4 deletions src/cohorts/cohorts.config.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
IsBoolean,
IsInt,
IsNumberString,
IsOptional,
IsString,
Max,
Min,
Expand All @@ -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 {
Expand Down
28 changes: 25 additions & 3 deletions src/cohorts/cohorts.config.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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})`);
}
Expand All @@ -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;
}
Expand Down
24 changes: 24 additions & 0 deletions src/cohorts/cohorts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
Patch,
Post,
Query,
Res,
StreamableFile,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
Expand All @@ -16,6 +18,7 @@ import {
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { Response } from 'express';
import {
CreateCohortRequestDto,
JoinWaitlistRequestDto,
Expand Down Expand Up @@ -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<StreamableFile> {
return this.cohortsService.getAttachment(id, filename, res);
}

@Get(':id')
@ApiOperation({ summary: 'Get a cohort by ID' })
async getCohort(
Expand All @@ -109,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<void> {
await this.cohortsService.syncQuestionsFromConfig(cohortId);
}

@Patch(':cohortId')
@ApiOperation({ summary: 'Update a cohort' })
@Roles(UserRole.TEACHING_ASSISTANT, UserRole.ADMIN)
Expand Down
Loading
Loading