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:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -13,8 +13,10 @@
ORDERING_FIXTURE,
RADIO_FIXTURE,
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
TRUE_FALSE_FIXTURE
TRUE_FALSE_FIXTURE,
WORD_BANK_FIXTURE
} from './question-fixtures';
const { Story } = defineMeta({
@@ -57,6 +59,16 @@
{/snippet}
</Story>
<Story name="WordBank">
{#snippet template()}
<QuestionTypeModes
question={WORD_BANK_FIXTURE.question}
answer={WORD_BANK_FIXTURE.answer}
wrongAnswer={WORD_BANK_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story
name="Hotspot"
parameters={{
@@ -127,6 +139,25 @@
{/snippet}
</Story>
<Story
name="Star"
parameters={{
docs: {
description: {
story: '**Premium in the app** – Gated behind a paid plan in the exercise editor.'
}
}
}}
>
{#snippet template()}
<QuestionTypeModes
question={STAR_FIXTURE.question}
answer={STAR_FIXTURE.answer}
wrongAnswer={STAR_FIXTURE.wrongAnswer}
/>
{/snippet}
</Story>
<Story name="Ordering">
{#snippet template()}
<QuestionTypeModes
@@ -131,6 +131,18 @@ export const NUMERIC_FIXTURE: QuestionStoryFixture = {
wrongAnswer: { type: 'NUMERIC', value: 42 }
};
export const STAR_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-star',
key: 'q-star',
title: 'How clear were the lesson objectives? [STAR]',
questionType: 'STAR',
settings: { correctValue: 4, maxStars: 5 }
},
answer: { type: 'STAR', value: 4 },
wrongAnswer: { type: 'STAR', value: 2 }
};
export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-fill-blank',
@@ -143,6 +155,29 @@ export const FILL_BLANK_FIXTURE: QuestionStoryFixture = {
wrongAnswer: { type: 'FILL_BLANK', values: ['SOAP'] }
};
export const WORD_BANK_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-word-bank',
key: 'q-word-bank',
title: 'Match each term with the sentence. [WORD_BANK]',
questionType: 'WORD_BANK',
settings: {
template:
'Measurements to describe our data are called ___. The ___ shows values and how often they occur. Outliers appear when data is skewed or has ___.',
correctAnswers: ['summary statistics', 'distribution', 'outliers'],
distractors: ['categorical variables', 'linear relationships', 'numeric variables']
}
},
answer: {
type: 'WORD_BANK',
filledBlanks: ['summary statistics', 'distribution', 'outliers']
},
wrongAnswer: {
type: 'WORD_BANK',
filledBlanks: ['numeric variables', 'distribution', 'linear relationships']
}
};
export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-file-upload',
@@ -66,10 +66,35 @@ export const QUESTION_LABELS = {
'numeric.review.your_answer_label': 'Submitted:',
'numeric.review.in_range': 'Within tolerance',
'numeric.review.out_of_range': 'Outside tolerance',
'star.edit.correct_value_label': 'Correct star value',
'star.edit.correct_value_info': 'Expected star count for full credit.',
'star.edit.max_stars_label': 'Maximum stars',
'star.edit.max_stars_info': 'Stars shown to learners (1–10).',
'star.edit.correct_value_placeholder': 'e.g. 3',
'star.edit.max_stars_placeholder': 'e.g. 5',
'star.take.helper': 'Select your rating.',
'star.take.star_sr': 'Star {n} of {max}',
'star.preview.correct_label': 'Correct answer',
'fill_blank.edit.accepted_answers_placeholder': 'Accepted answers (comma separated)',
'fill_blank.take.placeholder': 'Enter answers separated by commas',
'fill_blank.preview.accepted_answers_label': 'Accepted answers',
'fill_blank.review.your_answer_label': 'Submitted:',
'word_bank.edit.template_label': 'Sentence template',
'word_bank.edit.template_placeholder': 'Use ___ for each blank...',
'word_bank.edit.template_helper': 'Use three underscores (___) for each blank.',
'word_bank.edit.no_blanks_warning': 'Add at least one ___ blank marker in your template.',
'word_bank.edit.correct_answers_heading': 'Correct answer for each blank',
'word_bank.edit.blank_label': 'Blank',
'word_bank.edit.correct_placeholder': 'Correct term or phrase',
'word_bank.edit.distractors_heading': 'Extra words (distractors)',
'word_bank.edit.distractor_placeholder': 'Wrong option',
'word_bank.edit.add_distractor': 'Add distractor',
'word_bank.take.helper': 'Click or drag words into blanks.',
'word_bank.take.bank_heading': 'Word bank',
'word_bank.take.clear_blank_sr': 'Remove word from blank',
'word_bank.preview.filled_label': 'Expected answers in context',
'word_bank.review.your_answer_label': 'Your answers',
'word_bank.review.correct_label': 'Correct answers',
'file_upload.edit.accepted_types_placeholder': 'Select accepted file formats',
'file_upload.edit.max_size_placeholder': 'Max size (MB)',
'file_upload.take.selected_file_label': 'Selected file',
@@ -63,11 +63,45 @@
return { type: 'SHORT_ANSWER', text: 'Use deterministic fixtures' };
case 'NUMERIC':
return { type: 'NUMERIC', value: 88 + (index % 6) };
case 'STAR': {
const rawMax = storyQuestion.settings?.maxStars;
const maxStars =
typeof rawMax === 'number' && Number.isFinite(rawMax) ? Math.min(10, Math.max(1, Math.floor(rawMax))) : 5;
const rawCorrect = storyQuestion.settings?.correctValue;
const correct =
typeof rawCorrect === 'number' && Number.isFinite(rawCorrect)
? Math.min(maxStars, Math.max(1, Math.floor(rawCorrect)))
: Math.min(3, maxStars);
if (index % 4 === 0) return { type: 'STAR', value: correct };
return { type: 'STAR', value: (index % maxStars) + 1 };
}
case 'FILL_BLANK':
return {
type: 'FILL_BLANK',
values: index % 2 === 0 ? ['API'] : ['endpoint']
};
case 'WORD_BANK': {
const correct = Array.isArray(storyQuestion.settings?.correctAnswers)
? (storyQuestion.settings.correctAnswers as string[]).map((s) => String(s ?? ''))
: ['alpha', 'beta'];
const blankCount = Math.max(1, correct.length);
if (index % 4 === 0) {
return { type: 'WORD_BANK', filledBlanks: correct.slice(0, blankCount) };
}
if (index % 4 === 1) {
const wrongFirst = ['wrong term', ...correct.slice(1)].slice(0, blankCount);
return { type: 'WORD_BANK', filledBlanks: wrongFirst };
}
if (index % 4 === 2 && blankCount > 1) {
const swapped = [...correct.slice(0, blankCount)];
[swapped[0], swapped[1]] = [swapped[1], swapped[0]];
return { type: 'WORD_BANK', filledBlanks: swapped };
}
return {
type: 'WORD_BANK',
filledBlanks: Array.from({ length: blankCount }, () => '')
};
}
case 'FILE_UPLOAD':
return {
type: 'FILE_UPLOAD',