feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -9,14 +9,17 @@ import type {
|
||||
ShortAnswerData,
|
||||
NumericAnswerData,
|
||||
FillBlankAnswerData,
|
||||
WordBankAnswerData,
|
||||
FileUploadAnswerData,
|
||||
MatchingAnswerData,
|
||||
OrderingAnswerData,
|
||||
LinkAnswerData,
|
||||
HotspotAnswerData
|
||||
HotspotAnswerData,
|
||||
StarAnswerData
|
||||
} from './answer-data';
|
||||
|
||||
import { QUESTION_TYPE_KEY } from './question-type-keys';
|
||||
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
|
||||
|
||||
export type ApiPayload = { questionId: number; optionId?: number; answer?: string };
|
||||
|
||||
@@ -129,6 +132,23 @@ const FILL_BLANK_CODEC: AnswerCodec<FillBlankAnswerData> = {
|
||||
}
|
||||
};
|
||||
|
||||
const WORD_BANK_CODEC: AnswerCodec<WordBankAnswerData> = {
|
||||
type: 'WORD_BANK',
|
||||
toApiPayload(data, questionId) {
|
||||
return { questionId, answer: JSON.stringify({ type: 'WORD_BANK', filledBlanks: data.filledBlanks }) };
|
||||
},
|
||||
fromApiPayload(payload) {
|
||||
if (!payload.answer) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(payload.answer) as { type?: string; filledBlanks?: string[] };
|
||||
if (parsed?.type !== 'WORD_BANK' || !Array.isArray(parsed.filledBlanks)) return null;
|
||||
return { type: 'WORD_BANK', filledBlanks: parsed.filledBlanks.map((v) => String(v ?? '')) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const FILE_UPLOAD_CODEC: AnswerCodec<FileUploadAnswerData> = {
|
||||
type: 'FILE_UPLOAD',
|
||||
toApiPayload(data, questionId) {
|
||||
@@ -208,6 +228,25 @@ const HOTSPOT_CODEC: AnswerCodec<HotspotAnswerData> = {
|
||||
}
|
||||
};
|
||||
|
||||
const STAR_CODEC: AnswerCodec<StarAnswerData> = {
|
||||
type: 'STAR',
|
||||
toApiPayload(data, questionId) {
|
||||
return { questionId, answer: JSON.stringify({ type: 'STAR', value: data.value }) };
|
||||
},
|
||||
fromApiPayload(payload, question) {
|
||||
if (!payload.answer) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(payload.answer) as { type?: string; value?: unknown };
|
||||
if (parsed?.type !== 'STAR') return null;
|
||||
const maxStars = getStarRatingMaxFromSettings(question.settings);
|
||||
if (!isValidStarRatingValue(parsed.value, maxStars)) return null;
|
||||
return { type: 'STAR', value: parsed.value };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
|
||||
[QUESTION_TYPE_KEY.RADIO]: RADIO_CODEC,
|
||||
[QUESTION_TYPE_KEY.CHECKBOX]: CHECKBOX_CODEC,
|
||||
@@ -216,11 +255,13 @@ export const ANSWER_CODECS: Record<QuestionTypeKey, AnswerCodec> = {
|
||||
[QUESTION_TYPE_KEY.SHORT_ANSWER]: SHORT_ANSWER_CODEC,
|
||||
[QUESTION_TYPE_KEY.NUMERIC]: NUMERIC_CODEC,
|
||||
[QUESTION_TYPE_KEY.FILL_BLANK]: FILL_BLANK_CODEC,
|
||||
[QUESTION_TYPE_KEY.WORD_BANK]: WORD_BANK_CODEC,
|
||||
[QUESTION_TYPE_KEY.FILE_UPLOAD]: FILE_UPLOAD_CODEC,
|
||||
[QUESTION_TYPE_KEY.MATCHING]: MATCHING_CODEC,
|
||||
[QUESTION_TYPE_KEY.ORDERING]: ORDERING_CODEC,
|
||||
[QUESTION_TYPE_KEY.LINK]: LINK_CODEC,
|
||||
[QUESTION_TYPE_KEY.HOTSPOT]: HOTSPOT_CODEC
|
||||
[QUESTION_TYPE_KEY.HOTSPOT]: HOTSPOT_CODEC,
|
||||
[QUESTION_TYPE_KEY.STAR]: STAR_CODEC
|
||||
};
|
||||
|
||||
/** Convert any answer to API submission payload */
|
||||
@@ -267,8 +308,12 @@ export function extractAnswerDisplayValues(data: AnswerData): {
|
||||
return { selectedIds: [], selectedValues: [], text: data.text };
|
||||
case 'NUMERIC':
|
||||
return { selectedIds: [], selectedValues: [], text: String(data.value) };
|
||||
case 'STAR':
|
||||
return { selectedIds: [], selectedValues: [data.value], text: String(data.value) };
|
||||
case 'FILL_BLANK':
|
||||
return { selectedIds: [], selectedValues: [], text: data.values.join(', ') };
|
||||
case 'WORD_BANK':
|
||||
return { selectedIds: [], selectedValues: [], text: data.filledBlanks.filter(Boolean).join(', ') };
|
||||
case 'LINK':
|
||||
return { selectedIds: [], selectedValues: data.urls, text: data.urls.join(', ') };
|
||||
case 'MATCHING':
|
||||
|
||||
@@ -5,6 +5,7 @@ export type TextareaAnswerData = { type: 'TEXTAREA'; text: string };
|
||||
export type ShortAnswerData = { type: 'SHORT_ANSWER'; text: string };
|
||||
export type NumericAnswerData = { type: 'NUMERIC'; value: number };
|
||||
export type FillBlankAnswerData = { type: 'FILL_BLANK'; values: string[] };
|
||||
export type WordBankAnswerData = { type: 'WORD_BANK'; filledBlanks: string[] };
|
||||
export type FileUploadAnswerData = {
|
||||
type: 'FILE_UPLOAD';
|
||||
fileKey: string;
|
||||
@@ -16,6 +17,7 @@ export type MatchingAnswerData = { type: 'MATCHING'; pairs: Array<{ left: string
|
||||
export type OrderingAnswerData = { type: 'ORDERING'; orderedValues: string[] };
|
||||
export type LinkAnswerData = { type: 'LINK'; urls: string[] };
|
||||
export type HotspotAnswerData = { type: 'HOTSPOT'; coordinates: Array<{ x: number; y: number }> };
|
||||
export type StarAnswerData = { type: 'STAR'; value: number };
|
||||
|
||||
export type AnswerData =
|
||||
| RadioAnswerData
|
||||
@@ -25,8 +27,10 @@ export type AnswerData =
|
||||
| ShortAnswerData
|
||||
| NumericAnswerData
|
||||
| FillBlankAnswerData
|
||||
| WordBankAnswerData
|
||||
| FileUploadAnswerData
|
||||
| MatchingAnswerData
|
||||
| OrderingAnswerData
|
||||
| LinkAnswerData
|
||||
| HotspotAnswerData;
|
||||
| HotspotAnswerData
|
||||
| StarAnswerData;
|
||||
|
||||
@@ -66,10 +66,35 @@ export type ExerciseQuestionLabelKey =
|
||||
| 'numeric.review.your_answer_label'
|
||||
| 'numeric.review.in_range'
|
||||
| 'numeric.review.out_of_range'
|
||||
| 'star.edit.correct_value_label'
|
||||
| 'star.edit.correct_value_info'
|
||||
| 'star.edit.max_stars_label'
|
||||
| 'star.edit.max_stars_info'
|
||||
| 'star.edit.correct_value_placeholder'
|
||||
| 'star.edit.max_stars_placeholder'
|
||||
| 'star.take.helper'
|
||||
| 'star.take.star_sr'
|
||||
| 'star.preview.correct_label'
|
||||
| 'fill_blank.edit.accepted_answers_placeholder'
|
||||
| 'fill_blank.take.placeholder'
|
||||
| 'fill_blank.preview.accepted_answers_label'
|
||||
| 'fill_blank.review.your_answer_label'
|
||||
| 'word_bank.edit.template_label'
|
||||
| 'word_bank.edit.template_placeholder'
|
||||
| 'word_bank.edit.template_helper'
|
||||
| 'word_bank.edit.no_blanks_warning'
|
||||
| 'word_bank.edit.correct_answers_heading'
|
||||
| 'word_bank.edit.blank_label'
|
||||
| 'word_bank.edit.correct_placeholder'
|
||||
| 'word_bank.edit.distractors_heading'
|
||||
| 'word_bank.edit.distractor_placeholder'
|
||||
| 'word_bank.edit.add_distractor'
|
||||
| 'word_bank.take.helper'
|
||||
| 'word_bank.take.bank_heading'
|
||||
| 'word_bank.take.clear_blank_sr'
|
||||
| 'word_bank.preview.filled_label'
|
||||
| 'word_bank.review.your_answer_label'
|
||||
| 'word_bank.review.correct_label'
|
||||
| 'file_upload.edit.accepted_types_placeholder'
|
||||
| 'file_upload.edit.max_size_placeholder'
|
||||
| 'file_upload.take.selected_file_label'
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from './answer-data';
|
||||
export * from './answer-codecs';
|
||||
export * from './renderer-registry';
|
||||
export * from './textarea-validation';
|
||||
export * from './star-rating-settings';
|
||||
|
||||
@@ -8,11 +8,13 @@ export type QuestionAnswerPayload =
|
||||
| ShortAnswerPayload
|
||||
| NumericAnswerPayload
|
||||
| FillBlankAnswerPayload
|
||||
| WordBankAnswerPayload
|
||||
| FileUploadAnswerPayload
|
||||
| MatchingAnswerPayload
|
||||
| OrderingAnswerPayload
|
||||
| HotspotAnswerPayload
|
||||
| LinkAnswerPayload;
|
||||
| LinkAnswerPayload
|
||||
| StarAnswerPayload;
|
||||
|
||||
interface BaseAnswerPayload {
|
||||
type: QuestionTypeKey;
|
||||
@@ -53,6 +55,11 @@ export interface FillBlankAnswerPayload extends BaseAnswerPayload {
|
||||
answers: string[];
|
||||
}
|
||||
|
||||
export interface WordBankAnswerPayload extends BaseAnswerPayload {
|
||||
type: typeof QUESTION_TYPE_KEY.WORD_BANK;
|
||||
filledBlanks: string[];
|
||||
}
|
||||
|
||||
export interface FileUploadAnswerPayload extends BaseAnswerPayload {
|
||||
type: typeof QUESTION_TYPE_KEY.FILE_UPLOAD;
|
||||
fileId: string;
|
||||
@@ -80,3 +87,8 @@ export interface LinkAnswerPayload extends BaseAnswerPayload {
|
||||
type: typeof QUESTION_TYPE_KEY.LINK;
|
||||
links: string[];
|
||||
}
|
||||
|
||||
export interface StarAnswerPayload extends BaseAnswerPayload {
|
||||
type: typeof QUESTION_TYPE_KEY.STAR;
|
||||
value: number;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QUESTION_TYPE_KEY } from './question-type-keys';
|
||||
import { getQuestionTypeById } from './question-type-lookup';
|
||||
import { QUESTION_TYPE_ID_TO_KEY } from './question-type-registry';
|
||||
import { supportsPartialCredit } from './question-type-capabilities';
|
||||
import { getStarRatingMaxFromSettings, isValidStarRatingValue } from './star-rating-settings';
|
||||
|
||||
export type DbQuestionForScoring = {
|
||||
id: number;
|
||||
@@ -129,6 +130,20 @@ function scoreTrueFalse(
|
||||
return answer.value === correctIsTrue ? maxPoints : 0;
|
||||
}
|
||||
|
||||
function scoreStar(
|
||||
question: ExerciseQuestionModel,
|
||||
answer: Extract<AnswerData, { type: 'STAR' }>,
|
||||
maxPoints: number
|
||||
): number {
|
||||
const maxStars = getStarRatingMaxFromSettings(question.settings);
|
||||
const rawCorrect = question.settings?.correctValue;
|
||||
const correct = typeof rawCorrect === 'number' ? rawCorrect : rawCorrect != null ? Number(rawCorrect) : NaN;
|
||||
if (!isValidStarRatingValue(correct, maxStars)) return 0;
|
||||
if (!isValidStarRatingValue(answer.value, maxStars)) return 0;
|
||||
|
||||
return answer.value === correct ? maxPoints : 0;
|
||||
}
|
||||
|
||||
function scoreNumeric(
|
||||
question: ExerciseQuestionModel,
|
||||
answer: Extract<AnswerData, { type: 'NUMERIC' }>,
|
||||
@@ -169,6 +184,31 @@ function scoreFillBlank(
|
||||
return (correct / expected.length) * maxPoints;
|
||||
}
|
||||
|
||||
function scoreWordBank(
|
||||
question: ExerciseQuestionModel,
|
||||
answer: Extract<AnswerData, { type: 'WORD_BANK' }>,
|
||||
maxPoints: number,
|
||||
partial: boolean
|
||||
): number {
|
||||
const rawCorrect = question.settings?.correctAnswers;
|
||||
const expected = Array.isArray(rawCorrect)
|
||||
? (rawCorrect as unknown[]).map((s) => String(s).trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
if (expected.length === 0) return 0;
|
||||
|
||||
const studentVals = (answer.filledBlanks ?? []).map((v) => String(v).trim().toLowerCase());
|
||||
|
||||
let correct = 0;
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
if (studentVals[i] === expected[i]) correct++;
|
||||
}
|
||||
|
||||
if (!partial) {
|
||||
return correct === expected.length && studentVals.length === expected.length ? maxPoints : 0;
|
||||
}
|
||||
return (correct / expected.length) * maxPoints;
|
||||
}
|
||||
|
||||
function pairKey(left: string, right: string): string {
|
||||
return `${normalizeText(left)}::${normalizeText(right)}`;
|
||||
}
|
||||
@@ -298,9 +338,15 @@ export function scoreAnswerForQuestion(question: ExerciseQuestionModel, answer:
|
||||
case 'NUMERIC':
|
||||
if (key !== QUESTION_TYPE_KEY.NUMERIC) return 0;
|
||||
return scoreNumeric(question, answer, maxPoints);
|
||||
case 'STAR':
|
||||
if (key !== QUESTION_TYPE_KEY.STAR) return 0;
|
||||
return scoreStar(question, answer, maxPoints);
|
||||
case 'FILL_BLANK':
|
||||
if (key !== QUESTION_TYPE_KEY.FILL_BLANK) return 0;
|
||||
return clampPoints(scoreFillBlank(question, answer, maxPoints, partial), maxPoints);
|
||||
case 'WORD_BANK':
|
||||
if (key !== QUESTION_TYPE_KEY.WORD_BANK) return 0;
|
||||
return clampPoints(scoreWordBank(question, answer, maxPoints, partial), maxPoints);
|
||||
case 'MATCHING':
|
||||
if (key !== QUESTION_TYPE_KEY.MATCHING) return 0;
|
||||
return clampPoints(scoreMatching(question, answer, maxPoints, partial), maxPoints);
|
||||
|
||||
@@ -10,7 +10,9 @@ export const QUESTION_TYPE_KEY = {
|
||||
MATCHING: 'MATCHING',
|
||||
ORDERING: 'ORDERING',
|
||||
HOTSPOT: 'HOTSPOT',
|
||||
LINK: 'LINK'
|
||||
LINK: 'LINK',
|
||||
WORD_BANK: 'WORD_BANK',
|
||||
STAR: 'STAR'
|
||||
} as const;
|
||||
|
||||
export type QuestionTypeKey = (typeof QUESTION_TYPE_KEY)[keyof typeof QUESTION_TYPE_KEY];
|
||||
|
||||
@@ -109,6 +109,24 @@ export const QUESTION_TYPE_REGISTRY: readonly QuestionTypeMetadata[] = [
|
||||
autoGradable: false,
|
||||
supportsPartialCredit: false,
|
||||
manualGradingRequired: true
|
||||
},
|
||||
{
|
||||
key: QUESTION_TYPE_KEY.WORD_BANK,
|
||||
typename: 'WORD_BANK',
|
||||
label: 'Word bank',
|
||||
id: 13,
|
||||
autoGradable: true,
|
||||
supportsPartialCredit: true,
|
||||
manualGradingRequired: false
|
||||
},
|
||||
{
|
||||
key: QUESTION_TYPE_KEY.STAR,
|
||||
typename: 'STAR',
|
||||
label: 'Star rating',
|
||||
id: 14,
|
||||
autoGradable: true,
|
||||
supportsPartialCredit: false,
|
||||
manualGradingRequired: false
|
||||
}
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ExerciseQuestionModel } from './exercise-types';
|
||||
|
||||
const STAR_RATING_DEFAULT_MAX = 5;
|
||||
const STAR_RATING_ABSOLUTE_MAX = 10;
|
||||
|
||||
/** Max star count for STAR questions (from `question.settings.maxStars`, clamped). */
|
||||
export function getStarRatingMaxFromSettings(settings: ExerciseQuestionModel['settings']): number {
|
||||
const raw = settings?.maxStars;
|
||||
const parsed = typeof raw === 'number' ? raw : raw != null ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return STAR_RATING_DEFAULT_MAX;
|
||||
|
||||
return Math.min(STAR_RATING_ABSOLUTE_MAX, Math.max(1, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export function isValidStarRatingValue(value: unknown, maxStars: number): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= maxStars;
|
||||
}
|
||||
Reference in New Issue
Block a user