Skip to content

Commit 2c7333a

Browse files
Merge pull request #63 from foundersandcoders/claude/extract-utility-library-UTRy9
Refactor ILR mapping logic into dedicated modules
2 parents 1b112d3 + 3b92d7e commit 2c7333a

19 files changed

Lines changed: 447 additions & 344 deletions

.github/workflows/sync-engine.yml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Sync engine to schema-forge
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "src/lib/schema/**"
8+
- "src/lib/types/interpreterTypes.ts"
9+
- "src/lib/types/schemaTypes.ts"
10+
- "src/lib/transforms/**"
11+
- "src/lib/utils/schema/**"
12+
- "src/lib/utils/csv/**"
13+
- "src/lib/utils/xml/**"
14+
15+
jobs:
16+
sync:
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout Iris
21+
uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 2
24+
25+
- name: Check for engine changes
26+
id: changes
27+
run: |
28+
ENGINE_PATHS=(
29+
"src/lib/schema"
30+
"src/lib/types/interpreterTypes.ts"
31+
"src/lib/types/schemaTypes.ts"
32+
"src/lib/transforms"
33+
"src/lib/utils/schema"
34+
"src/lib/utils/csv"
35+
"src/lib/utils/xml"
36+
)
37+
38+
CHANGED=false
39+
for path in "${ENGINE_PATHS[@]}"; do
40+
if git diff --name-only HEAD~1 HEAD -- "$path" | grep -q .; then
41+
CHANGED=true
42+
break
43+
fi
44+
done
45+
46+
echo "changed=$CHANGED" >> "$GITHUB_OUTPUT"
47+
48+
- name: Clone schema-forge
49+
if: steps.changes.outputs.changed == 'true'
50+
run: |
51+
git clone https://x-access-token:${{ secrets.SCHEMA_FORGE_PAT }}@github.com/JasonWarrenUK/schema-forge.git /tmp/schema-forge
52+
53+
- name: Sync engine files
54+
if: steps.changes.outputs.changed == 'true'
55+
run: |
56+
TARGET=/tmp/schema-forge/src
57+
58+
# Clear previous engine content (full replace, not merge)
59+
rm -rf "$TARGET"
60+
mkdir -p "$TARGET"
61+
62+
# Copy engine modules preserving structure
63+
cp -r src/lib/schema "$TARGET/schema"
64+
cp -r src/lib/transforms "$TARGET/transforms"
65+
cp -r src/lib/utils/schema "$TARGET/utils/schema"
66+
cp -r src/lib/utils/csv "$TARGET/utils/csv"
67+
cp -r src/lib/utils/xml "$TARGET/utils/xml"
68+
69+
mkdir -p "$TARGET/types"
70+
cp src/lib/types/interpreterTypes.ts "$TARGET/types/"
71+
cp src/lib/types/schemaTypes.ts "$TARGET/types/"
72+
73+
- name: Commit and push
74+
if: steps.changes.outputs.changed == 'true'
75+
working-directory: /tmp/schema-forge
76+
run: |
77+
git config user.name "iris-sync[bot]"
78+
git config user.email "iris-sync[bot]@users.noreply.github.com"
79+
80+
git add -A
81+
if git diff --cached --quiet; then
82+
echo "No changes to commit"
83+
exit 0
84+
fi
85+
86+
COMMIT_SHA="${{ github.sha }}"
87+
git commit -m "sync: update engine from iris@${COMMIT_SHA:0:7}
88+
89+
Source: ${{ github.server_url }}/${{ github.repository }}/commit/${COMMIT_SHA}"
90+
git push

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/mappings/compatibility.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { MappingConfig } from '../types/schemaTypes';
77
import type { IrisStorage } from '../storage';
88
import { buildSchemaRegistry } from '../schema/registryBuilder';
99
import { validateSchemaCompatibility, type CompatibilityResult } from '../schema/schemaCompatibility';
10+
import { FAM_PATHS, APP_FIN_PATHS, LLDD_PATHS, EMPLOYMENT_PATHS } from './builderPaths';
1011

1112
export interface MappingCompatibilityResult {
1213
success: boolean;
@@ -45,8 +46,16 @@ export async function validateMappingCompatibility(params: {
4546
};
4647
}
4748

49+
// Compute ILR-specific builder paths to validate
50+
const builderPaths = [
51+
...(mapping.famTemplates?.length ? FAM_PATHS : []),
52+
...(mapping.appFinTemplates?.length ? APP_FIN_PATHS : []),
53+
...(mapping.employmentStatuses?.length ? EMPLOYMENT_PATHS : []),
54+
...LLDD_PATHS,
55+
];
56+
4857
// Validate compatibility
49-
const compatibility = validateSchemaCompatibility(mapping, registry);
58+
const compatibility = validateSchemaCompatibility(mapping, registry, builderPaths);
5059

5160
return {
5261
success: true,

src/lib/mappings/fac-airtable-2025.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type { MappingConfig } from '../types/schemaTypes';
8-
import { generateAimMappings } from '../utils/config/mapping';
8+
import { generateAimMappings } from './aimUtils';
99

1010
export const facAirtableMapping: MappingConfig = {
1111
id: 'fac-airtable-2025',
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { ColumnMapping, MappingConfig } from '../types/schemaTypes';
2+
import type { SchemaRegistry } from '../types/interpreterTypes';
3+
import { mapCsvToSchema } from '../schema/columnMapper';
4+
import { getTransform } from '../transforms/registry';
5+
import { hasAimData } from './aimUtils';
6+
import {
7+
buildFamEntries,
8+
buildAppFinRecords,
9+
buildEmploymentStatuses,
10+
buildLLDDFields,
11+
} from './builders';
12+
import { generateUUID } from '../utils/uuid';
13+
14+
/**
15+
* Maps a single CSV row to ILR structure with support for multiple LearningDelivery aims
16+
*
17+
* Detects which aims (1-5) have data and creates separate LearningDelivery elements for each.
18+
* Auto-generates AimSeqNumber based on the aim group number.
19+
*
20+
* @param csvRow - Object with CSV column headers as keys
21+
* @param config - Mapping configuration with aim-grouped mappings
22+
* @param registry - Schema registry for path validation
23+
* @returns Nested object structure with multiple LearningDelivery elements
24+
*/
25+
export function mapCsvToSchemaWithAims(
26+
csvRow: Record<string, string>,
27+
config: MappingConfig,
28+
registry: SchemaRegistry
29+
): Record<string, unknown> {
30+
// Separate learner-level and aim-specific mappings
31+
const learnerMappings = config.mappings.filter((m) => !m.aimNumber);
32+
const aimMappings = config.mappings.filter((m) => m.aimNumber);
33+
34+
// Group aim mappings by aim number
35+
const aimGroups = new Map<number, ColumnMapping[]>();
36+
for (const mapping of aimMappings) {
37+
if (!mapping.aimNumber) continue;
38+
if (!aimGroups.has(mapping.aimNumber)) {
39+
aimGroups.set(mapping.aimNumber, []);
40+
}
41+
aimGroups.get(mapping.aimNumber)!.push(mapping);
42+
}
43+
44+
// Apply learner-level mappings using generic mapper
45+
const result = mapCsvToSchema(csvRow, learnerMappings, registry);
46+
47+
// Build LLDD fields with conditional logic
48+
const llddFields = buildLLDDFields(csvRow, 'Primary additional needs');
49+
const messagePart = result.Message as Record<string, unknown>;
50+
if (messagePart && messagePart.Learner) {
51+
const learnerArray = messagePart.Learner as Record<string, unknown>[];
52+
if (learnerArray.length > 0) {
53+
learnerArray[0].LLDDHealthProb = llddFields.LLDDHealthProb;
54+
if (llddFields.LLDDandHealthProblem) {
55+
learnerArray[0].LLDDandHealthProblem = llddFields.LLDDandHealthProblem;
56+
}
57+
}
58+
}
59+
60+
// Build employment status entries
61+
const statuses = buildEmploymentStatuses(csvRow, config.employmentStatuses);
62+
if (statuses.length > 0) {
63+
const messagePart = result.Message as Record<string, unknown>;
64+
if (messagePart && messagePart.Learner) {
65+
const learnerArray = messagePart.Learner as Record<string, unknown>[];
66+
if (learnerArray.length > 0) learnerArray[0].LearnerEmploymentStatus = statuses;
67+
}
68+
}
69+
70+
// Detect which aims have data and build LearningDelivery elements
71+
const detectionField = config.aimDetectionField || 'Programme aim {n} Learning ref';
72+
const learningDeliveries: Record<string, unknown>[] = [];
73+
74+
for (const [aimNumber, mappings] of aimGroups.entries()) {
75+
if (!hasAimData(csvRow, aimNumber, detectionField)) continue;
76+
77+
const delivery: Record<string, unknown> = { AimSeqNumber: aimNumber };
78+
79+
for (const mapping of mappings) {
80+
const columnKey = Object.keys(csvRow).find(
81+
(key) => key.trim().toLowerCase() === mapping.csvColumn.trim().toLowerCase()
82+
);
83+
84+
if (!columnKey) continue;
85+
86+
const rawValue = csvRow[columnKey];
87+
const value = mapping.transform ? getTransform(mapping.transform)(rawValue) : rawValue;
88+
89+
// Extract just the field name from the full path
90+
// e.g., "Message.Learner.LearningDelivery.LearnAimRef" -> "LearnAimRef"
91+
const pathParts = mapping.xsdPath.split('.');
92+
const fieldName = pathParts[pathParts.length - 1];
93+
94+
delivery[fieldName] = value;
95+
}
96+
97+
// Generate SWSupAimId (software-generated UUID for tracking)
98+
delivery.SWSupAimId = generateUUID();
99+
100+
// Build FAM entries for this aim
101+
const fams = buildFamEntries(csvRow, config.famTemplates, aimNumber);
102+
if (fams.length > 0) delivery.LearningDeliveryFAM = fams;
103+
104+
// Build AppFinRecord entries for this aim
105+
const fins = buildAppFinRecords(csvRow, config.appFinTemplates, aimNumber);
106+
if (fins.length > 0) delivery.AppFinRecord = fins;
107+
108+
learningDeliveries.push(delivery);
109+
}
110+
111+
// Inject LearningDelivery array into result structure
112+
if (learningDeliveries.length > 0) {
113+
const messagePart = result.Message as Record<string, unknown>;
114+
if (messagePart && messagePart.Learner) {
115+
const learnerArray = messagePart.Learner as Record<string, unknown>[];
116+
if (learnerArray.length > 0) {
117+
learnerArray[0].LearningDelivery = learningDeliveries;
118+
}
119+
}
120+
}
121+
122+
return result;
123+
}

src/lib/mappings/ilrValidation.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { ColumnMapping } from '../types/schemaTypes';
2+
import { hasAimData } from './aimUtils';
3+
4+
/**
5+
* Creates a skip filter for aim-specific mappings during CSV validation.
6+
*
7+
* - At header level (no row): skips all aim-specific mappings (validated per-row instead)
8+
* - At row level: skips aim mappings where the aim has no data
9+
*
10+
* @param detectionField - Field template with {n} placeholder for aim detection
11+
*/
12+
export function createAimSkipFilter(detectionField: string = 'Programme aim {n} Learning ref') {
13+
return (mapping: ColumnMapping, row?: Record<string, string>): boolean => {
14+
if (!mapping.aimNumber) return false;
15+
if (!row) return true; // header-level: skip all aim mappings
16+
return !hasAimData(row, mapping.aimNumber, detectionField);
17+
};
18+
}

0 commit comments

Comments
 (0)