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,7 +9,7 @@ This guide walks through adding a new question type to ClassroomIO. Question typ
|
||||
A question type flows through several layers:
|
||||
|
||||
1. **`@cio/question-types`** — Keys, metadata, answer serialization (codecs)
|
||||
2. **`@cio/db`** — Database seed/migration for the `question_type` table
|
||||
2. **`@cio/db`** — Document the type in `schema.ts` and add a seed row for `question_type` (no migration file for reference data)
|
||||
3. **`@cio/ui`** — Edit, take, and preview renderers
|
||||
4. **`apps/dashboard`** — Translation keys, question type picker
|
||||
5. **`apps/api`** — Uses the above via `@cio/question-types`; no code changes usually needed
|
||||
@@ -151,9 +151,17 @@ export type ExerciseQuestionLabelKey =
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Database — seed and migration
|
||||
## Step 6: Database — `schema.ts` and seed
|
||||
|
||||
### 6a. Seed (for new environments)
|
||||
**Do not add a migration file** (nothing new under `packages/db/src/migrations/`) for a new question type. Reference data like `question_type` rows is not versioned via Drizzle migrations in this repo. Updating **`schema.ts`** (documentation only, unless you change table structure) and the **seed** is enough.
|
||||
|
||||
### 6a. Update `schema.ts` documentation
|
||||
|
||||
**File:** `packages/db/src/schema.ts`
|
||||
|
||||
On the `questionType` table, extend the JSDoc comment that lists expected ids and typenames (e.g. `1 RADIO`, `2 CHECKBOX`, …) so it includes your new type and id. That keeps the database package aligned with `QUESTION_TYPE_REGISTRY` for anyone reading the schema.
|
||||
|
||||
### 6b. Seed (for new environments)
|
||||
|
||||
**File:** `packages/db/src/utils/seed/questionType.ts`
|
||||
|
||||
@@ -168,17 +176,11 @@ Add a seed entry so new databases get the type:
|
||||
}
|
||||
```
|
||||
|
||||
The `id` is auto-generated. Use the next sequential id (e.g. 13) in `QUESTION_TYPE_REGISTRY` so it matches after seed runs.
|
||||
The `id` is auto-generated. Use the next sequential id in `QUESTION_TYPE_REGISTRY` so it matches the order rows get when seed runs (or document the expected id in both places).
|
||||
|
||||
### 6b. Migration (for existing databases)
|
||||
### 6c. Existing / production databases
|
||||
|
||||
For existing production databases, add a migration that inserts the new question type:
|
||||
|
||||
```sql
|
||||
INSERT INTO question_type (label, typename) VALUES ('My New Type', 'MY_NEW_TYPE');
|
||||
```
|
||||
|
||||
If you need a specific `id` to match the registry, you may need to use `SELECT setval(...)` or an `ON CONFLICT`-style approach depending on your migration strategy.
|
||||
For databases that already exist and will not re-run seed, insert the `question_type` row **outside** the migration folder (e.g. a one-off SQL run in staging/production, or your ops playbook). Do **not** commit a migration whose only purpose is `INSERT INTO question_type`.
|
||||
|
||||
---
|
||||
|
||||
@@ -303,7 +305,8 @@ In `constants.ts`, you can:
|
||||
- [ ] `AnswerCodec` (`toApiPayload`, `fromApiPayload`) in `answer-codecs.ts` and registration in `ANSWER_CODECS`
|
||||
- [ ] Optional: payload in `question-answer-payload.ts`
|
||||
- [ ] Optional: label keys in `exercise-types.ts`
|
||||
- [ ] Seed in `questionType.ts` and migration for existing DBs
|
||||
- [ ] `schema.ts` comment on `questionType` updated with the new id/typename
|
||||
- [ ] Seed entry in `questionType.ts` (no new migration file for `INSERT INTO question_type`)
|
||||
- [ ] Three renderers (edit, take, preview) in `@cio/ui`
|
||||
- [ ] Registration in `EXERCISE_QUESTION_RENDERER_CONTRACT`
|
||||
- [ ] Translation keys in dashboard `constants.ts` and `en.json`
|
||||
@@ -328,4 +331,4 @@ In `constants.ts`, you can:
|
||||
| HOTSPOT | 11 | Auto-gradable, partial credit |
|
||||
| LINK | 12 | Manual grading |
|
||||
|
||||
Use the next available id (13) for your new type, and ensure the DB `question_type` row has a matching id.
|
||||
Use the next available id for your new type, keep `QUESTION_TYPE_REGISTRY` and the `questionType` comment in `schema.ts` in sync, and ensure environments that need the row have it inserted (seed or one-off—**not** a committed migration for reference data).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -111,6 +111,22 @@ const EXPECTED_REGISTRY = [
|
||||
autoGradable: false,
|
||||
supportsPartialCredit: false,
|
||||
manualGradingRequired: true
|
||||
},
|
||||
{
|
||||
key: QUESTION_TYPE_KEY.WORD_BANK,
|
||||
typename: 'WORD_BANK',
|
||||
id: 13,
|
||||
autoGradable: true,
|
||||
supportsPartialCredit: true,
|
||||
manualGradingRequired: false
|
||||
},
|
||||
{
|
||||
key: QUESTION_TYPE_KEY.STAR,
|
||||
typename: 'STAR',
|
||||
id: 14,
|
||||
autoGradable: true,
|
||||
supportsPartialCredit: false,
|
||||
manualGradingRequired: false
|
||||
}
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user