Skip to content

Commit 4834304

Browse files
authored
feat(data-exchange): DataExchangeJob, DataMappingProfile + OpenConnector delegation + OSO parent-review gate (#53)
Implements the data-exchange capability end-to-end (Phase 2 LAST spec): Schemas (33 → 35): - DataMappingProfile (slug data-mapping-profile): field mappings from Scholiq schema to external target; transforms bsn-to-pseudonym/date-iso8601/cohort-to-brin; seed profiles for BRON/ROD, OSO, leerplicht. - DataExchangeJob (slug data-exchange-job): job queue with lifecycle queued→running→succeeded|failed|partial; OSO target adds pending-parent-review gate; jobFinished notification; durationSeconds+successRate calculations; artefactRef as OR file attachment. PHP (ADR-031 bridge exceptions): - DataExchangeRunHandler: loads mapping profile, queries source objects, applies field transforms (BSN never extracted — uses eckId pseudonym only), delegates to OpenConnector REST API; no wire protocols in Scholiq. - DataExchangeRunGuard: blocks queued→running directly for OSO target. - OsoDossierReviewGuard: verifies approveDossier actor is in LearnerProfile.parentIds. - AttendanceFlagCreationHandler: fulfils TODO — creates DataExchangeJob on threshold crossing, sets dataExchangeJobId on flag, removes _dataExchangeTargetIntent. - AttendanceFlagReportGuard: fulfils TODO — requires linked DataExchangeJob to be succeeded before allowing reported transition. Frontend: DataMappingProfiles/Detail + DataExchangeJobs/Detail pages; custom RequestExportModal (target+profile+scope picker with delegation notice) and OsoDossierReviewView (parent approval with ECK-iD shown, BSN not shown). Nav "Data Exchange" order 60. All gates pass: validate-json-strict, validate-register, validate-manifest (0 Ajv errors), phpcs clean, phpstan 0 errors, eslint 0 errors, stylelint clean, build OK.
1 parent aa31b69 commit 4834304

23 files changed

Lines changed: 10171 additions & 7168 deletions

l10n/en.json

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,71 @@
382382
"Submit excuse request": "Submit excuse request",
383383
"The \"until\" date must be on or after the \"from\" date.": "The \"until\" date must be on or after the \"from\" date.",
384384
"Failed to submit excuse request. Please try again.": "Failed to submit excuse request. Please try again.",
385-
"Try again": "Try again"
385+
"Try again": "Try again",
386+
"scholiq.nav.data_exchange": "Data Exchange",
387+
"scholiq.page.data_mapping_profiles.title": "Mapping Profiles",
388+
"scholiq.page.data_mapping_profile.detail.title": "Mapping Profile",
389+
"scholiq.page.data_exchange_jobs.title": "Data Exchange Jobs",
390+
"scholiq.page.data_exchange_job.detail.title": "Data Exchange Job",
391+
"scholiq.page.request_export.title": "Request Data Exchange",
392+
"scholiq.page.oso_dossier_review.title": "OSO Dossier Review",
393+
"Request data exchange": "Request data exchange",
394+
"Direction": "Direction",
395+
"Export (Scholiq → external)": "Export (Scholiq → external)",
396+
"Import (external → Scholiq)": "Import (external → Scholiq)",
397+
"Sync (bidirectional)": "Sync (bidirectional)",
398+
"Target (OpenConnector connection)": "Target (OpenConnector connection)",
399+
"— select a target —": "— select a target —",
400+
"BRON/ROD (DUO)": "BRON/ROD (DUO)",
401+
"OSO transfer (PO→VO)": "OSO transfer (PO→VO)",
402+
"Leerplicht (Digikoppeling)": "Leerplicht (Digikoppeling)",
403+
"SURFconext attributes": "SURFconext attributes",
404+
"HR system sync": "HR system sync",
405+
"Custom…": "Custom…",
406+
"Custom connection name": "Custom connection name",
407+
"OSO jobs enter 'pending-parent-review' before executing. A parent must approve the dossier before it is sent.": "OSO jobs enter 'pending-parent-review' before executing. A parent must approve the dossier before it is sent.",
408+
"Mapping profile (optional)": "Mapping profile (optional)",
409+
"— none (pass raw objects) —": "— none (pass raw objects) —",
410+
"Source schema": "Source schema",
411+
"— select a schema —": "— select a schema —",
412+
"Cohort (optional)": "Cohort (optional)",
413+
"Cohort UUID or leave empty for all": "Cohort UUID or leave empty for all",
414+
"Period (optional)": "Period (optional)",
415+
"e.g. 2025-2026/1 or leave empty": "e.g. 2025-2026/1 or leave empty",
416+
"Queuing…": "Queuing…",
417+
"Queue job": "Queue job",
418+
"Queue another job": "Queue another job",
419+
"Job {state}": "Job {state}",
420+
"{accepted} of {total} records accepted": "{accepted} of {total} records accepted",
421+
"Job {state} — checking status…": "Job {state} — checking status…",
422+
"Scholiq has delegated this job to the OpenConnector '{target}' connection. No wire-protocol code runs in Scholiq.": "Scholiq has delegated this job to the OpenConnector '{target}' connection. No wire-protocol code runs in Scholiq.",
423+
"Scholiq queues this job and delegates wire-protocol execution to the named OpenConnector connection. No Edukoppeling, StUF, OSO-XML, Digikoppeling, or SAML code runs inside Scholiq.": "Scholiq queues this job and delegates wire-protocol execution to the named OpenConnector connection. No Edukoppeling, StUF, OSO-XML, Digikoppeling, or SAML code runs inside Scholiq.",
424+
"Failed to queue job. Please try again.": "Failed to queue job. Please try again.",
425+
"Loading OSO dossier…": "Loading OSO dossier…",
426+
"DataExchangeJob not found.": "DataExchangeJob not found.",
427+
"OSO Transfer Dossier Review": "OSO Transfer Dossier Review",
428+
"Job {id} · Target: {target} · Status: {status}": "Job {id} · Target: {target} · Status: {status}",
429+
"This dossier is in '{state}' state and can no longer be reviewed.": "This dossier is in '{state}' state and can no longer be reviewed.",
430+
"This OSO transfer dossier is awaiting parent approval. Review the data below and approve or reject.": "This OSO transfer dossier is awaiting parent approval. Review the data below and approve or reject.",
431+
"ECK iD": "ECK iD",
432+
"School ID": "School ID",
433+
"Date of birth": "Date of birth",
434+
"BSN is not shown or transmitted. The ECK iD pseudonym is used for identity.": "BSN is not shown or transmitted. The ECK iD pseudonym is used for identity.",
435+
"Learner profile not found.": "Learner profile not found.",
436+
"Grade entries ({count})": "Grade entries ({count})",
437+
"No grade entries for this learner.": "No grade entries for this learner.",
438+
"Attendance records ({count})": "Attendance records ({count})",
439+
"No attendance records for this learner.": "No attendance records for this learner.",
440+
"Rejection reason (required to reject)": "Rejection reason (required to reject)",
441+
"Why are you rejecting this dossier?": "Why are you rejecting this dossier?",
442+
"Approving…": "Approving…",
443+
"Approve and send": "Approve and send",
444+
"Rejecting…": "Rejecting…",
445+
"Reject dossier": "Reject dossier",
446+
"Approval failed. Are you listed as a parent for this learner?": "Approval failed. Are you listed as a parent for this learner?",
447+
"Rejection failed. Please try again.": "Rejection failed. Please try again.",
448+
"No job ID in URL.": "No job ID in URL.",
449+
"Failed to load job. Please try again.": "Failed to load job. Please try again."
386450
},
387451
"plurals": ""
388452
}

l10n/nl.json

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,71 @@
382382
"Submit excuse request": "Verzuimmelding indienen",
383383
"The \"until\" date must be on or after the \"from\" date.": "De \"tot\"-datum moet op of na de \"van\"-datum liggen.",
384384
"Failed to submit excuse request. Please try again.": "Indienen van de melding mislukt. Probeer het opnieuw.",
385-
"Try again": "Opnieuw proberen"
385+
"Try again": "Opnieuw proberen",
386+
"scholiq.nav.data_exchange": "Gegevensuitwisseling",
387+
"scholiq.page.data_mapping_profiles.title": "Koppelingsprofielen",
388+
"scholiq.page.data_mapping_profile.detail.title": "Koppelingsprofiel",
389+
"scholiq.page.data_exchange_jobs.title": "Gegevensuitwisselingtaken",
390+
"scholiq.page.data_exchange_job.detail.title": "Gegevensuitwisselingtaak",
391+
"scholiq.page.request_export.title": "Gegevensuitwisseling aanvragen",
392+
"scholiq.page.oso_dossier_review.title": "OSO-dossier beoordelen",
393+
"Request data exchange": "Gegevensuitwisseling aanvragen",
394+
"Direction": "Richting",
395+
"Export (Scholiq → external)": "Exporteren (Scholiq → extern)",
396+
"Import (external → Scholiq)": "Importeren (extern → Scholiq)",
397+
"Sync (bidirectional)": "Synchroniseren (bidirectioneel)",
398+
"Target (OpenConnector connection)": "Doel (OpenConnector-koppeling)",
399+
"— select a target —": "— selecteer een doel —",
400+
"BRON/ROD (DUO)": "BRON/ROD (DUO)",
401+
"OSO transfer (PO→VO)": "OSO-overdracht (PO→VO)",
402+
"Leerplicht (Digikoppeling)": "Leerplicht (Digikoppeling)",
403+
"SURFconext attributes": "SURFconext-attributen",
404+
"HR system sync": "HR-systeem synchroniseren",
405+
"Custom…": "Eigen koppeling…",
406+
"Custom connection name": "Naam van de eigen koppeling",
407+
"OSO jobs enter 'pending-parent-review' before executing. A parent must approve the dossier before it is sent.": "OSO-taken krijgen eerst de status 'wacht op oudergoedkeuring'. Een ouder moet het dossier goedkeuren voordat het wordt verzonden.",
408+
"Mapping profile (optional)": "Koppelingsprofiel (optioneel)",
409+
"— none (pass raw objects) —": "— geen (ruwe objecten doorgeven) —",
410+
"Source schema": "Bronschema",
411+
"— select a schema —": "— selecteer een schema —",
412+
"Cohort (optional)": "Cohort (optioneel)",
413+
"Cohort UUID or leave empty for all": "Cohort-UUID of leeg laten voor alle",
414+
"Period (optional)": "Periode (optioneel)",
415+
"e.g. 2025-2026/1 or leave empty": "bijv. 2025-2026/1 of leeg laten",
416+
"Queuing…": "Taak wordt aangemeld…",
417+
"Queue job": "Taak aanmelden",
418+
"Queue another job": "Nog een taak aanmelden",
419+
"Job {state}": "Taak {state}",
420+
"{accepted} of {total} records accepted": "{accepted} van {total} records geaccepteerd",
421+
"Job {state} — checking status…": "Taak {state} — status controleren…",
422+
"Scholiq has delegated this job to the OpenConnector '{target}' connection. No wire-protocol code runs in Scholiq.": "Scholiq heeft deze taak gedelegeerd aan de OpenConnector-koppeling '{target}'. Er wordt geen communicatieprotocol uitgevoerd binnen Scholiq.",
423+
"Scholiq queues this job and delegates wire-protocol execution to the named OpenConnector connection. No Edukoppeling, StUF, OSO-XML, Digikoppeling, or SAML code runs inside Scholiq.": "Scholiq registreert deze taak en delegeert de uitvoering van het communicatieprotocol aan de genoemde OpenConnector-koppeling. Binnen Scholiq wordt geen Edukoppeling-, StUF-, OSO-XML-, Digikoppeling- of SAML-code uitgevoerd.",
424+
"Failed to queue job. Please try again.": "Taak aanmelden mislukt. Probeer het opnieuw.",
425+
"Loading OSO dossier…": "OSO-dossier laden…",
426+
"DataExchangeJob not found.": "Gegevensuitwisselingtaak niet gevonden.",
427+
"OSO Transfer Dossier Review": "Beoordeling OSO-overdrachtssdossier",
428+
"Job {id} · Target: {target} · Status: {status}": "Taak {id} · Doel: {target} · Status: {status}",
429+
"This dossier is in '{state}' state and can no longer be reviewed.": "Dit dossier heeft de status '{state}' en kan niet meer worden beoordeeld.",
430+
"This OSO transfer dossier is awaiting parent approval. Review the data below and approve or reject.": "Dit OSO-overdrachtssdossier wacht op goedkeuring van een ouder. Beoordeel de onderstaande gegevens en keur goed of af.",
431+
"ECK iD": "ECK iD",
432+
"School ID": "School-ID",
433+
"Date of birth": "Geboortedatum",
434+
"BSN is not shown or transmitted. The ECK iD pseudonym is used for identity.": "Het BSN wordt niet getoond of verzonden. Het ECK iD-pseudoniem wordt gebruikt voor identificatie.",
435+
"Learner profile not found.": "Leerlingprofiel niet gevonden.",
436+
"Grade entries ({count})": "Cijfervermeldingen ({count})",
437+
"No grade entries for this learner.": "Geen cijfervermeldingen voor deze leerling.",
438+
"Attendance records ({count})": "Aanwezigheidsregistraties ({count})",
439+
"No attendance records for this learner.": "Geen aanwezigheidsregistraties voor deze leerling.",
440+
"Rejection reason (required to reject)": "Afwijzingsreden (verplicht om af te wijzen)",
441+
"Why are you rejecting this dossier?": "Waarom wijst u dit dossier af?",
442+
"Approving…": "Goedkeuren…",
443+
"Approve and send": "Goedkeuren en verzenden",
444+
"Rejecting…": "Afwijzen…",
445+
"Reject dossier": "Dossier afwijzen",
446+
"Approval failed. Are you listed as a parent for this learner?": "Goedkeuring mislukt. Bent u vermeld als ouder voor deze leerling?",
447+
"Rejection failed. Please try again.": "Afwijzen mislukt. Probeer het opnieuw.",
448+
"No job ID in URL.": "Geen taak-ID in de URL.",
449+
"Failed to load job. Please try again.": "Taak laden mislukt. Probeer het opnieuw."
386450
},
387451
"plurals": ""
388452
}

lib/AppInfo/Application.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use OCA\Scholiq\Lifecycle\ExcuseApprovalHandler;
2828
use OCA\Scholiq\Lifecycle\XapiCompletionHandler;
2929
use OCA\Scholiq\Listener\CredentialIssuanceHandler;
30+
use OCA\Scholiq\Listener\DataExchangeRunHandler;
3031
use OCA\Scholiq\Listener\DeepLinkRegistrationListener;
3132
use OCA\Scholiq\Listener\GradeRollupHandler;
3233
use OCA\Scholiq\Listener\LearningPlanEvaluationHandler;
@@ -135,14 +136,25 @@ public function register(IRegistrationContext $context): void
135136

136137
// ADR-031 legitimate exception: AttendanceThreshold calculatedChange crossing → AttendanceFlag creation.
137138
// When OR fires a threshold-crossed event for an AttendanceThreshold, the handler
138-
// creates an AttendanceFlag (open) with mentor/window/metric details. It does NOT
139-
// auto-act against the learner. DataExchangeJob queueing is deferred to the
140-
// data-exchange spec (TODO marker in the handler).
139+
// creates an AttendanceFlag (open) with mentor/window/metric details and, when
140+
// onCross.dataExchangeTarget is set, queues a DataExchangeJob to that target.
141+
// It does NOT auto-act against the learner.
141142
$context->registerEventListener(
142143
event: ObjectTransitionedEvent::class,
143144
listener: AttendanceFlagCreationHandler::class
144145
);
145146

147+
// ADR-031 legitimate exception: DataExchangeJob lifecycle → running bridge.
148+
// When a DataExchangeJob transitions to `running`, the handler loads the
149+
// DataMappingProfile, queries source objects, applies field transforms
150+
// (bsn-to-pseudonym using eckId, date-iso8601, cohort-to-brin), and delegates
151+
// to OpenConnector via REST API. No wire protocols are implemented in Scholiq;
152+
// all Edukoppeling/StUF/OSO-XML/Digikoppeling/SAML logic lives in OpenConnector.
153+
$context->registerEventListener(
154+
event: ObjectTransitionedEvent::class,
155+
listener: DataExchangeRunHandler::class
156+
);
157+
146158
}//end register()
147159

148160
/**

lib/Lifecycle/AttendanceFlagCreationHandler.php

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class AttendanceFlagCreationHandler implements IEventListener
5959
private const ATTENDANCE_THRESHOLD_SCHEMA = 'attendance-threshold';
6060
private const ATTENDANCE_FLAG_SCHEMA = 'attendance-flag';
6161
private const LEARNER_PROFILE_SCHEMA = 'learner-profile';
62+
private const DATA_EXCHANGE_JOB_SCHEMA = 'data-exchange-job';
6263

6364
/**
6465
* The transition name used by OR when a calculatedChange crossing fires.
@@ -184,12 +185,22 @@ private function createFlag(ObjectTransitionedEvent $event): void
184185
// Resolve mentor from LearnerProfile.managerId.
185186
$mentorId = $this->resolveMentorId(learnerId: $learnerId);
186187

187-
// Build the flag. dataExchangeJobId is null for now — the data-exchange
188-
// spec will wire up the actual DataExchangeJob creation and set this field.
189-
// TODO(data-exchange spec): queue a DataExchangeJob to onCross.dataExchangeTarget
190-
// and set dataExchangeJobId on the flag once that spec lands.
191188
$dataExchangeTarget = $onCross['dataExchangeTarget'] ?? null;
192189

190+
// Queue a DataExchangeJob for the configured target (e.g. 'leerplicht')
191+
// when the threshold's onCross.dataExchangeTarget is set. The job is created
192+
// first so its UUID can be set on the flag's dataExchangeJobId field.
193+
$dataExchangeJobId = null;
194+
if ($dataExchangeTarget !== null && $dataExchangeTarget !== '') {
195+
$dataExchangeJobId = $this->queueDataExchangeJob(
196+
target: $dataExchangeTarget,
197+
learnerId: $learnerId,
198+
windowStart: $windowStart,
199+
windowEnd: $windowEnd,
200+
tenantId: $tenantId
201+
);
202+
}
203+
193204
$flag = [
194205
'learnerId' => $learnerId,
195206
'attendanceThresholdId' => $thresholdId,
@@ -198,18 +209,12 @@ private function createFlag(ObjectTransitionedEvent $event): void
198209
'windowEnd' => $windowEnd,
199210
'metricValue' => (float) $metricValue,
200211
'breachingRecordIds' => $breachingIds,
201-
'dataExchangeJobId' => null,
212+
'dataExchangeJobId' => $dataExchangeJobId,
202213
'mentorId' => $mentorId,
203214
'lifecycle' => 'open',
204215
'tenant_id' => $tenantId,
205216
];
206217

207-
// Record the dataExchangeTarget intent on the flag for visibility even
208-
// before the data-exchange spec wires up the actual job.
209-
if ($dataExchangeTarget !== null) {
210-
$flag['_dataExchangeTargetIntent'] = $dataExchangeTarget;
211-
}
212-
213218
$this->objectService->saveObject(
214219
register: self::SCHOLIQ_REGISTER,
215220
schema: self::ATTENDANCE_FLAG_SCHEMA,
@@ -229,6 +234,74 @@ private function createFlag(ObjectTransitionedEvent $event): void
229234

230235
}//end createFlag()
231236

237+
/**
238+
* Create and queue a DataExchangeJob for the given target.
239+
*
240+
* Called when an AttendanceThreshold's onCross.dataExchangeTarget is set.
241+
* The job is created in `queued` state; the DataExchangeRunHandler will
242+
* execute it when the lifecycle engine transitions it to `running`.
243+
*
244+
* @param string $target Named OpenConnector connection (e.g. 'leerplicht').
245+
* @param string $learnerId NC user ID of the learner who crossed the threshold.
246+
* @param string $windowStart Start date of the measurement window (Y-m-d).
247+
* @param string $windowEnd End date of the measurement window (Y-m-d).
248+
* @param string $tenantId Tenant UUID.
249+
*
250+
* @return string|null UUID of the created DataExchangeJob, or null on failure.
251+
*/
252+
private function queueDataExchangeJob(
253+
string $target,
254+
string $learnerId,
255+
string $windowStart,
256+
string $windowEnd,
257+
string $tenantId,
258+
): ?string {
259+
$job = [
260+
'direction' => 'export',
261+
'target' => $target,
262+
'scope' => [
263+
'schema' => 'attendance-flag',
264+
'filters' => ['learnerId' => $learnerId],
265+
'cohortId' => null,
266+
'period' => $windowStart.'/'.$windowEnd,
267+
],
268+
'requestedBy' => 'system',
269+
'requestedAt' => date('c'),
270+
'lifecycle' => 'queued',
271+
'tenant_id' => $tenantId,
272+
];
273+
274+
$saved = $this->objectService->saveObject(
275+
register: self::SCHOLIQ_REGISTER,
276+
schema: self::DATA_EXCHANGE_JOB_SCHEMA,
277+
object: $job
278+
);
279+
280+
if ($saved === null) {
281+
$this->logger->warning(
282+
'[AttendanceFlagCreationHandler] Failed to queue DataExchangeJob for target {t}, learner {l}.',
283+
['t' => $target, 'l' => $learnerId]
284+
);
285+
return null;
286+
}
287+
288+
if (is_array($saved) === true) {
289+
$savedData = $saved;
290+
} else {
291+
$savedData = $saved->jsonSerialize();
292+
}
293+
294+
$jobId = $savedData['id'] ?? ($savedData['uuid'] ?? null);
295+
296+
$this->logger->info(
297+
'[AttendanceFlagCreationHandler] Queued DataExchangeJob {id} to target {t} for learner {l}.',
298+
['id' => $jobId, 't' => $target, 'l' => $learnerId]
299+
);
300+
301+
return $jobId;
302+
303+
}//end queueDataExchangeJob()
304+
232305
/**
233306
* Resolve the learner's mentor from their LearnerProfile.managerId.
234307
*

0 commit comments

Comments
 (0)