Assessments
Web/Admin Assessments provides creation, assignment, scoring, and reporting for child assessments. It mirrors backend assessment rules and exposes streamlined operator flows with critical business logic for subscription validation, age restrictions, and assessor assignment.
Core Concepts
- Assessment Types: CDDC (Comprehensive Developmental Domain Checklist) vs Standard assessments
- Subscription Validation: Only children with relevant subscription plans can access assessments
- Age Restrictions: CDDC covers 0-216 months (0-18 years), others have custom ranges
- Assessor Assignment: Parent vs Professional assessors with different access controls
- Status Flow: DRAFT → IN_PROGRESS → COMPLETED → ARCHIVED
- Scoring System: Scale-based (0-4) with section-wise aggregation
Web/Admin Capabilities
1) List & Filter
Business Rules:
- Filter by subscription plan, assessor type, language, and status
- CDDC assessments show full age range (0-216 months)
- Standard assessments show custom age ranges
- Parent-assessable assessments are marked as "Parent" in assessor column
Implementation:
// Filter logic with subscription and assessor validation
const filtered = rows.filter((assessment) => {
// Subscription plan validation
const hasValidSubscription =
!subscriptionFilter ||
assessment.assessment_subscriptions.some((sub) =>
subscriptionFilter.includes(sub.subscription_id),
);
// Assessor type validation (Parent vs Professional)
const hasValidAssessor =
!assessorFilter ||
(assessorFilter === 'parent' && !assessment.is_private) ||
(assessorFilter !== 'parent' &&
assessment.assessment_specialties.some(
(spec) => spec.specialty_id === assessorFilter,
));
return hasValidSubscription && hasValidAssessor;
});
2) Create Assessment (Admin-Only)
Critical Business Rules:
- Subscription Validation: Child must have matching subscription plan
- Age Restrictions: CDDC (0-216 months), others (min_age < max_age, max 3 digits)
- Assessor Logic: Parent selection makes assessment public
- CDDC Special Rules: Age fields auto-populate to full range, validation skipped
- Media Upload: Required image, optional question media with UUID conversion
Validation Formula:
// Age validation logic
const validateAgeRange = (
assessmentType: string,
minAge: number,
maxAge: number,
) => {
if (assessmentType === 'CDDC') {
// CDDC: Auto-set to 0-216 months, skip validation
return { min_age: 0, max_age: 216, valid: true };
}
// Standard assessments: Custom range validation
const isValid = minAge >= 0 && maxAge <= 999 && minAge < maxAge;
return { min_age: minAge, max_age: maxAge, valid: isValid };
};
// Assessor assignment logic
const determinePrivacy = (assessorList: string[]) => {
const hasParent = assessorList.includes('parent');
return {
is_private: !hasParent,
filtered_assessors: assessorList.filter(
(assessor) => assessor !== 'parent',
),
};
};
GraphQL Implementation:
import { gql, ApolloClient } from '@apollo/client';
const CREATE_ASSESSMENT = gql`
mutation CreateAssessment($input: CreateAssessmentInput!) {
createAssessment(input: $input) {
success
message
data {
id
status
is_private
min_age
max_age
}
}
}
`;
async function createAssessment(
client: ApolloClient<unknown>,
input: CreateAssessmentInput,
) {
// Pre-process: Handle assessor logic and age validation
const processedInput = {
...input,
...determinePrivacy(input.assessor_list),
...validateAgeRange(input.assessment_type, input.min_age, input.max_age),
};
const { data } = await client.mutate({
mutation: CREATE_ASSESSMENT,
variables: { input: processedInput },
});
if (!data?.createAssessment?.success) {
throw new Error(data?.createAssessment?.message || 'Create failed');
}
return data.createAssessment.data;
}
3) Scoring System & Validation
Scoring Business Rules:
- Scale System: 0-4 scale for all questions (0=Not at all, 4=Very well)
- Section Aggregation: Average scores per CDDC domain (Gross Motor, Fine Motor, etc.)
- Validation: Only numeric values (0-4) allowed for scale questions
- Auto-save: Real-time saving with optimistic updates
- Access Control: Parent assessors can only score public assessments
Scoring Formula:
// Score validation and aggregation
const validateScore = (answerType: string, score: string) => {
if (answerType === 'SCALE') {
const numScore = Number(score);
return !isNaN(numScore) && numScore >= 0 && numScore <= 4;
}
return true; // Other answer types have different validation
};
// Section score calculation
const calculateSectionScore = (questions: Question[], sectionId: string) => {
const sectionQuestions = questions.filter((q) => q.section === sectionId);
const validScores = sectionQuestions
.map((q) => q.score)
.filter((score) => score !== null && score !== undefined);
if (validScores.length === 0) return null;
return (
validScores.reduce((sum, score) => sum + score, 0) / validScores.length
);
};
GraphQL Implementation:
const SAVE_SCORE = gql`
mutation SaveScore($input: SaveAssessmentScoreInput!) {
saveAssessmentScore(input: $input) {
success
message
data {
id
section_id
score
remark
calculated_section_average
}
}
}
`;
async function saveScore(
client: ApolloClient<unknown>,
assessmentId: string,
sectionId: string,
score: number,
remark: string,
) {
// Validate score before submission
if (!validateScore('SCALE', score.toString())) {
throw new Error('Score must be between 0-4 for scale questions');
}
const { data } = await client.mutate({
mutation: SAVE_SCORE,
variables: {
input: {
assessment_id: assessmentId,
section_id: sectionId,
score,
remark,
},
},
});
if (!data?.saveAssessmentScore?.success) {
throw new Error(data?.saveAssessmentScore?.message || 'Save failed');
}
return data.saveAssessmentScore.data;
}
4) Assessment Completion & Status Management
Completion Business Rules:
- Prerequisites: All required questions must be scored (0-4 scale)
- Section Validation: Each CDDC domain must have minimum required questions answered
- Admin Override: Admins can complete assessments even with missing scores
- Status Transition: DRAFT → IN_PROGRESS → COMPLETED → ARCHIVED
- Archive Rules: Only completed assessments can be archived
Completion Validation:
// Check if assessment can be completed
const canCompleteAssessment = (assessment: Assessment) => {
const requiredQuestions = assessment.questions.filter((q) => q.isRequired);
const answeredQuestions = requiredQuestions.filter(
(q) => q.score !== null && q.score !== undefined,
);
// All required questions must be answered
const allRequiredAnswered =
answeredQuestions.length === requiredQuestions.length;
// For CDDC: Each domain must have at least one answered question
const cddcDomains = [
'gross_motor',
'fine_motor',
'cognitive',
'language',
'social',
];
const domainValidation =
assessment.assessment_type === 'CDDC'
? cddcDomains.every((domain) =>
assessment.questions.some(
(q) => q.section === domain && q.score !== null,
),
)
: true;
return allRequiredAnswered && domainValidation;
};
GraphQL Implementation:
const COMPLETE_ASSESSMENT = gql`
mutation CompleteAssessment($input: CompleteAssessmentInput!) {
completeAssessment(input: $input) {
success
message
data {
id
status
completion_percentage
section_scores {
domain
average_score
questions_answered
}
}
}
}
`;
async function completeAssessment(client: ApolloClient<unknown>, id: string) {
// Pre-validate completion requirements
const assessment = await getAssessmentDetails(id);
if (!canCompleteAssessment(assessment)) {
throw new Error(
'Cannot complete: Missing required scores or incomplete sections',
);
}
const { data } = await client.mutate({
mutation: COMPLETE_ASSESSMENT,
variables: { input: { assessment_id: id } },
});
if (!data?.completeAssessment?.success) {
throw new Error(data?.completeAssessment?.message || 'Complete failed');
}
return data.completeAssessment.data;
}
5) Export & Reporting
Export Business Rules:
- Data Scope: Include scores, section averages, and completion status
- Privacy: Only export data for assessments user has access to
- Format: CSV for raw data, PDF for formatted reports
- CDDC Reports: Include domain-wise breakdowns and developmental indicators
Export Implementation:
// Generate assessment report data
const generateAssessmentReport = (assessments: Assessment[]) => {
return assessments.map((assessment) => ({
id: assessment.id,
name: assessment.name,
child_name: assessment.child?.name,
completion_percentage: calculateCompletionPercentage(assessment),
section_scores: assessment.sections.map((section) => ({
domain: section.name,
average_score: calculateSectionScore(assessment.questions, section.id),
questions_answered: section.questions.filter((q) => q.score !== null)
.length,
})),
overall_score: calculateOverallScore(assessment),
completed_at: assessment.completed_at,
}));
};
const csv = toCsv(generateAssessmentReport(rows));
downloadCsv('assessments_report.csv', csv);
Data Flow (Web ↔ Backend)
Critical Business Logic Flow:
- Creation: Admin creates assessment → Subscription validation → Age range validation → Assessor assignment
- Assignment: Child assigned assessment → Subscription check → Access granted/denied
- Scoring: Assessor scores questions → Real-time validation → Auto-save with optimistic updates
- Completion: All required scores → Section validation → Status transition → Report generation
GraphQL Operations with Business Rules:
| Action | Operation | Business Rules |
|---|---|---|
| Create | createAssessment(input) | Subscription validation, age restrictions, assessor logic |
| Save Score | saveAssessmentScore(input) | 0-4 scale validation, section aggregation |
| Complete | completeAssessment(input) | Required questions check, CDDC domain validation |
| List/Filter | assessments(filter) | Subscription-based access, assessor type filtering |
| Export | exportAssessmentReport(input) | Privacy controls, section-wise aggregation |
Error Handling & Validation:
- Subscription Mismatch: "Child does not have required subscription plan"
- Age Out of Range: "Assessment not suitable for child's age group"
- Invalid Score: "Score must be between 0-4 for scale questions"
- Incomplete Sections: "Cannot complete: Missing required scores in [domain]"
- Access Denied: "Insufficient permissions to access this assessment"
Security & Access Control
- Admin-Only Creation: Only admins can create and assign assessments
- Subscription-Based Access: Children can only access assessments matching their subscription
- Assessor Permissions: Parent assessors limited to public assessments only
- Data Privacy: Export and reporting respect user access levels
- Audit Trail: All scoring and completion actions are logged for compliance
Bulk Upload
Use the admin bulk uploader to create multiple assessments at once. Two templates are supported: CDDC and Screening. Sample CSV links are configured in env: NEXT_PUBLIC_CDDC_SAMPLE_CSV_URL and NEXT_PUBLIC_SCREENING_SAMPLE_CSV_URL.
CDDC Template
- Required headers:
title,description,key_pointers,language,section_name,assessors,subscription_plans,completion_time_in_min,question_1…question_36 - Validation:
title,descriptionrequiredcompletion_time_in_minis a non-negative integerlanguagemust match reference languagessection_namemust match a valid CDDC sectionassessorsmust map to known specialties; at least onesubscription_plansmust map to known plans; at least one- All
question_1…question_36are required
Screening Template
- Required headers:
title,description,key_pointers,min_age,max_age,language,assessors,subscription_plans,completion_time_in_min, and each 3-question group for domains:gm,fm,adl,rl,el,cog,soc,emo(e.g.,gm_question_1..gm_question_3) - Validation:
title,descriptionrequiredmin_age,max_agenon-negative andmax_age >= min_agecompletion_time_in_minnon-negative integerlanguagemust match reference languagesassessorsmap to specialties (≥1)subscription_plansmap to plans (≥1)- All domain questions present; missing are reported as
GMQ1etc.
General behavior
- Header matching ignores spaces/special chars (case-insensitive)
- Preview lists row number and validation errors; only valid rows are submitted
- Backend payload includes resolved ids for
language,assessors,subscription_plans, plus activities/questions
Implementation (TypeScript)
// Header normalization and mapping
const clean = (s: string) =>
s
.replace(/\s+/g, '')
.replace(/[^a-zA-Z0-9]/g, '')
.toLowerCase();
function validateAndMapHeaders(headers: string[], type: 'CDDC' | 'SCREENING') {
const expected = type === 'CDDC' ? CDDC_HEADERS : SCREENING_HEADERS; // from constants
const required =
type === 'CDDC' ? RequiredCDDCHeaders : RequiredScreeningHeaders;
const headerMap: Record<string, string> = {};
const unmapped: string[] = [];
headers.forEach((h) => {
const match = expected.find((e) => clean(e) === clean(h));
match ? (headerMap[h] = match) : unmapped.push(h);
});
const missingRequired = required.filter(
(r) => !headers.map(clean).includes(clean(r)),
);
return {
isValid: missingRequired.length === 0,
missingRequired,
unmappedHeaders: unmapped,
headerMap,
};
}
// Row validation + transform
function validateAndTransformData(
rows: CSVRow[],
ref: ReferenceData,
type: 'CDDC' | 'SCREENING',
) {
const display: DisplayAssessment[] = [];
const backend: BackendAssessment[] = [];
rows.forEach((row, idx) => {
const errors: string[] = [];
// Required fields
if (!row.title?.trim()) errors.push('Title is required');
if (!row.description?.trim()) errors.push('Description is required');
// time
const minutes = parseInt(row.completion_time_in_min);
if (isNaN(minutes) || minutes < 0)
errors.push('Completion time must be >= 0');
// language
const language = ref.languages.find(
(l) => l.title.toLowerCase() === row.language?.toLowerCase(),
);
if (!language) errors.push('Invalid language');
// section (CDDC only)
if (type === 'CDDC') {
if (!row.section_name?.trim()) errors.push('Section name is required');
const sectionOk = ref.sectionNames.some(
(sn) => sn.value.toLowerCase() === row.section_name?.toLowerCase(),
);
if (row.section_name?.trim() && !sectionOk)
errors.push('Invalid section name');
} else {
// age (SCREENING only)
const minAge = parseInt(row.min_age || '');
const maxAge = parseInt(row.max_age || '');
if (isNaN(minAge) || minAge < 0) errors.push('Min age must be >= 0');
if (isNaN(maxAge) || maxAge < 0) errors.push('Max age must be >= 0');
if (!isNaN(minAge) && !isNaN(maxAge) && maxAge < minAge)
errors.push('Max age must be >= Min age');
}
// assessors
const assessorTitles =
row.assessors
?.split(',')
.map((s) => s.trim())
.filter(Boolean) || [];
const assessorIds = assessorTitles
.map(
(t) =>
ref.specialities.find(
(s) => s.title.toLowerCase() === t.toLowerCase(),
)?.id,
)
.filter((id): id is string => !!id);
if (assessorIds.length !== assessorTitles.length)
errors.push('Invalid assessors');
if (assessorIds.length === 0) errors.push('At least one assessor required');
// subscriptions
const subTitles =
row.subscription_plans
?.split(',')
.map((s) => s.trim())
.filter(Boolean) || [];
const subIds = subTitles
.map(
(t) =>
ref.subscriptionPlans.find(
(p) => p.name.toLowerCase() === t.toLowerCase(),
)?.id,
)
.filter((id): id is string => !!id);
if (subIds.length !== subTitles.length)
errors.push('Invalid subscription plans');
if (subIds.length === 0)
errors.push('At least one subscription plan required');
// activities/questions
const activities: Activity[] = [];
const missing: string[] = [];
if (type === 'CDDC') {
for (let i = 1; i <= 36; i++) {
const q = row[`question_${i}`]?.trim();
if (!q) missing.push(String(i));
else activities.push({ heading: `Question ${i}`, description: q });
}
if (missing.length)
errors.push(
missing.length === 1
? `Question ${missing[0]} is required`
: `Questions ${missing.join(', ')} are required`,
);
} else {
const cats = ['gm', 'fm', 'adl', 'rl', 'el', 'cog', 'soc', 'emo'];
cats.forEach((p) => {
for (let i = 1; i <= 3; i++) {
const key = `${p}_question_${i}`;
const v = row[key]?.trim();
if (!v) missing.push(`${p.toUpperCase()}Q${i}`);
else
activities.push({
heading: `${p.toUpperCase()}Q${i}`,
description: v,
});
}
});
if (missing.length)
errors.push(
missing.length === 1
? `${missing[0]} is required`
: `${missing.join(', ')} are required`,
);
}
// collect
display.push({
title: row.title?.trim() || '',
description: row.description?.trim() || '',
completion_time_in_min: minutes,
...(type === 'SCREENING' && {
min_age: parseInt(row.min_age || ''),
max_age: parseInt(row.max_age || ''),
}),
language_title: language?.title || row.language || '',
language_id: language?.id || '',
section_name: row.section_name?.trim() || '',
assessor_titles: assessorTitles,
assessor_ids: assessorIds,
subscription_titles: subTitles,
subscription_ids: subIds,
activities: activities.map((a) => a.description),
row_number: idx + 2,
validation_errors: errors,
});
if (errors.length === 0) {
backend.push({
name: row.title!.trim(),
description: row.description!.trim(),
completion_time_in_min: minutes,
language_id: language!.id,
section_name: row.section_name!.trim(),
assessment_specialties: assessorIds,
assessment_subscriptions: subIds,
activities,
});
}
});
return { display, backend };
}