Skip to main content

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:

  1. Creation: Admin creates assessment → Subscription validation → Age range validation → Assessor assignment
  2. Assignment: Child assigned assessment → Subscription check → Access granted/denied
  3. Scoring: Assessor scores questions → Real-time validation → Auto-save with optimistic updates
  4. Completion: All required scores → Section validation → Status transition → Report generation

GraphQL Operations with Business Rules:

ActionOperationBusiness Rules
CreatecreateAssessment(input)Subscription validation, age restrictions, assessor logic
Save ScoresaveAssessmentScore(input)0-4 scale validation, section aggregation
CompletecompleteAssessment(input)Required questions check, CDDC domain validation
List/Filterassessments(filter)Subscription-based access, assessor type filtering
ExportexportAssessmentReport(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_1question_36
  • Validation:
    • title, description required
    • completion_time_in_min is a non-negative integer
    • language must match reference languages
    • section_name must match a valid CDDC section
    • assessors must map to known specialties; at least one
    • subscription_plans must map to known plans; at least one
    • All question_1question_36 are 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, description required
    • min_age, max_age non-negative and max_age >= min_age
    • completion_time_in_min non-negative integer
    • language must match reference languages
    • assessors map to specialties (≥1)
    • subscription_plans map to plans (≥1)
    • All domain questions present; missing are reported as GMQ1 etc.

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 };
}