diff --git a/modules/redcap/CONFIGURATION.md b/modules/redcap/CONFIGURATION.md
index 559b174bdd0..40ea03d9743 100644
--- a/modules/redcap/CONFIGURATION.md
+++ b/modules/redcap/CONFIGURATION.md
@@ -20,6 +20,11 @@ The configuration is of the following form:
visit_1
arm_1
event_1
+
+ My Site
+ My Project
+ My Cohort
+
@@ -28,6 +33,8 @@ The configuration is of the following form:
## Detailed description
+### General configuration
+
The configuration nodes are the following:
- `redcap` (required): Root node of the LORIS REDCap configuration.
- `instance` (required, multiple allowed): The list of instance entries to synchronize with LORIS.
@@ -36,15 +43,27 @@ In an `instance` entry, the configuration parameters are the following:
- `redcap-url` (required): The URL of the REDCap instance. This is also the URL of the REDCap instance API without the `api` suffix.
- `project` (required, multiple allowed): The list of project entries in this REDCap instance to synchronize with LORIS.
+### Project configuration
+
In a `project` entry, the configuration parameters are the following:
- `redcap-project-id` (required): The REDCap project ID of the REDCap project described by this entry.
- `redcap-api-token` (required): The REDCap API token used by LORIS to retrieve REDCap data for this project.
- `prefix-instrument-variable` (optional): Whether or not the instrument field variable names are prefixed by their instrument name in REDCap. The two options are `true` or `false`. If not present, `false` is used.
- `redcap-participant-id` (optional): The type of REDCap participant identifier used to map the REDCap participants with the LORIS candidates. The two options are `record_id` and `survey_participant_id`. If not present, `record_id` is used.
- `candidate-id` (optional): The type of LORIS candidate identifier used to map the REDCap participants with the LORIS candidates. The two options are `candid` and `pscid`. If not present, `pscid` is used.
-- `visit` (optional, multiple allowed): The list of visit entries that describe how REDCap arms and events are mapped to LORIS visits. If not present, the REDCap arms are ignored and the REDCap event names are matched to LORIS visit labels with the same name.
+- `visit` (optional, multiple allowed): A list of visit mappings that describe how REDCap arms and events are mapped to LORIS visits. If not present, the REDCap arms are ignored and the REDCap event names are matched to LORIS visit labels with the same name.
+
+### Visit mapping configuration
In a `visit` entry, the configuration parameters are the following:
- `visit-label` (required): The LORIS visit label of the visit to which to attach the instrument responses that match this entry.
- `redcap-arm-name` (optional): The REDCap arm name that the instrument responses must match to be attached to this visit. If not present, the arm name is ignored when filtering instrument responses.
- `redcap-event-name` (optional): The REDCap event name that the instrument responses must match to be attached to this visit. If not present, the event name is ignored when filtering instrument responses.
+- `create-session` (optional): The information needed to create the LORIS session for the candidate and visit associated with a REDCap event if it does not already exist.
+
+### Session creation configuration
+
+In a `create-session` entry, the configuration parameters are the following:
+- `site-name`: The name of the LORIS site for which to create the session if it does not already exist.
+- `project-name`: The name of the LORIS project for which to create the session visit if it does not already exist.
+- `cohort-name`: The name of the LORIS cohort for which to create the session visit if it does not already exist.
diff --git a/modules/redcap/php/client/models/mappings/iredcapinstrumentevent.class.inc b/modules/redcap/php/client/models/mappings/iredcapinstrumentevent.class.inc
index 80663d2305f..bf9d453f343 100644
--- a/modules/redcap/php/client/models/mappings/iredcapinstrumentevent.class.inc
+++ b/modules/redcap/php/client/models/mappings/iredcapinstrumentevent.class.inc
@@ -24,11 +24,11 @@ namespace LORIS\redcap\client\models\mappings;
interface IRedcapInstrumentEvent
{
/**
- * Get the REDCap event name.
+ * Get the REDCap unique event name.
*
* @return string
*/
- public function getEventName(): string;
+ public function getUniqueEventName(): string;
/**
* Get the REDCap form name.
diff --git a/modules/redcap/php/client/models/mappings/redcapinstrumenteventmap.class.inc b/modules/redcap/php/client/models/mappings/redcapinstrumenteventmap.class.inc
index 0f83a82db0f..e352a3bd8e0 100644
--- a/modules/redcap/php/client/models/mappings/redcapinstrumenteventmap.class.inc
+++ b/modules/redcap/php/client/models/mappings/redcapinstrumenteventmap.class.inc
@@ -61,11 +61,11 @@ class RedcapInstrumentEventMap implements IRedcapInstrumentEvent
}
/**
- * Get the REDCap event name.
+ * Get the REDCap unique event name.
*
* @return string
*/
- public function getEventName(): string
+ public function getUniqueEventName(): string
{
return $this->unique_event_name;
}
diff --git a/modules/redcap/php/client/models/mappings/redcaprepeatinginstrumentevent.class.inc b/modules/redcap/php/client/models/mappings/redcaprepeatinginstrumentevent.class.inc
index 086adb8cf3c..2f155d5771c 100644
--- a/modules/redcap/php/client/models/mappings/redcaprepeatinginstrumentevent.class.inc
+++ b/modules/redcap/php/client/models/mappings/redcaprepeatinginstrumentevent.class.inc
@@ -26,12 +26,11 @@ use LORIS\redcap\client\RedcapProps;
class RedcapRepeatingInstrumentEvent implements IRedcapInstrumentEvent
{
/**
- * Redcap Event name.
- * This is linked to a UNIQUE event name.
+ * A REDCap unique event name.
*
* @var string
*/
- public readonly string $event_name;
+ public readonly string $unique_event_name;
/**
* Redcap Form name.
@@ -57,19 +56,19 @@ class RedcapRepeatingInstrumentEvent implements IRedcapInstrumentEvent
{
$props = new RedcapProps('repeating_instrument', $props);
- $this->event_name = $props->getString('event_name');
- $this->form_name = $props->getString('form_name');
- $this->custom_label = $props->getStringNullable('custom_form_label');
+ $this->unique_event_name = $props->getString('event_name');
+ $this->form_name = $props->getString('form_name');
+ $this->custom_label = $props->getStringNullable('custom_form_label');
}
/**
- * Get the REDCap event name.
+ * Get the REDCap unique event name.
*
* @return string
*/
- public function getEventName(): string
+ public function getUniqueEventName(): string
{
- return $this->event_name;
+ return $this->unique_event_name;
}
/**
@@ -91,7 +90,7 @@ class RedcapRepeatingInstrumentEvent implements IRedcapInstrumentEvent
{
return [
'form_name' => $this->form_name,
- 'event_name' => $this->event_name,
+ 'event_name' => $this->unique_event_name,
'custom_form_label' => $this->custom_label
];
}
diff --git a/modules/redcap/php/client/models/records/redcaprecord.class.inc b/modules/redcap/php/client/models/records/redcaprecord.class.inc
index 2ee331ce7f9..16dcd1d1670 100644
--- a/modules/redcap/php/client/models/records/redcaprecord.class.inc
+++ b/modules/redcap/php/client/models/records/redcaprecord.class.inc
@@ -12,6 +12,8 @@
namespace LORIS\redcap\client\models\records;
+use LORIS\redcap\client\RedcapProps;
+
/**
* This represents a redcap record.
*
@@ -23,6 +25,30 @@ namespace LORIS\redcap\client\models\records;
*/
class RedcapRecord implements IRedcapRecord
{
+ /**
+ * The REDCap record ID.
+ */
+ public readonly string $record_id;
+
+ /**
+ * The REDCap unique event name.
+ */
+ public readonly string $unique_event_name;
+
+ /**
+ * The record completion status.
+ * 0 = incomplete / partial survey response
+ * 1 = unverified
+ * 2 = complete
+ */
+ public readonly int $complete;
+
+ /**
+ * The date and time at which the record was completed. This is an undocumented
+ * field in the REDCap API.
+ */
+ public readonly \DateTimeImmutable $datetime;
+
private string $_form_name;
private array $_props;
@@ -37,6 +63,14 @@ class RedcapRecord implements IRedcapRecord
{
$this->_form_name = $form_name;
$this->_props = $props;
+
+ $props = new RedcapProps('record', $props);
+
+ $datetime_string = $props->getString("{$form_name}_timestamp");
+ $this->record_id = $props->getString('record_id');
+ $this->unique_event_name = $props->getString('redcap_event_name');
+ $this->complete = $props->getInt("{$form_name}_complete");
+ $this->datetime = new \DateTimeImmutable($datetime_string);
}
/**
diff --git a/modules/redcap/php/client/models/redcapnotification.class.inc b/modules/redcap/php/client/models/redcapnotification.class.inc
index aac3845c083..fbee514982f 100644
--- a/modules/redcap/php/client/models/redcapnotification.class.inc
+++ b/modules/redcap/php/client/models/redcapnotification.class.inc
@@ -29,7 +29,7 @@ class RedcapNotification
public readonly string $project_id;
public readonly string $instrument_name;
public readonly string $record_id;
- public readonly string $event_name;
+ public readonly string $unique_event_name;
public readonly string $username;
public readonly string $complete;
@@ -75,7 +75,7 @@ class RedcapNotification
$this->project_id = $props['project_id'];
$this->project_url = $props['project_url'];
$this->record_id = $props['record'];
- $this->event_name = $props['redcap_event_name'];
+ $this->unique_event_name = $props['redcap_event_name'];
$this->instance_url = $props['redcap_url'];
$this->complete = $props[$complete_key] ?? '';
$this->username = $props['username'];
@@ -104,7 +104,7 @@ class RedcapNotification
'project_url' => $this->project_url,
'received_dt' => $this->received_datetime->format('Y-m-d H:i:s'),
'record' => $this->record_id,
- 'redcap_event_name' => $this->event_name,
+ 'redcap_event_name' => $this->unique_event_name,
'redcap_url' => $this->instance_url,
'complete' => $this->complete,
'username' => $this->username,
diff --git a/modules/redcap/php/client/redcaphttpclient.class.inc b/modules/redcap/php/client/redcaphttpclient.class.inc
index c5ea74c2e56..0c640399555 100644
--- a/modules/redcap/php/client/redcaphttpclient.class.inc
+++ b/modules/redcap/php/client/redcaphttpclient.class.inc
@@ -47,7 +47,7 @@ class RedcapHttpClient
private bool $_verbose;
/**
- * REDCap URL.
+ * REDCap API URL.
*/
private string $_url;
@@ -71,18 +71,20 @@ class RedcapHttpClient
/**
* Create a new REDCap Client for a specific REDCap instance and project.
*
- * @param string $instance_api_url A REDCap instance API URL.
+ * @param string $instance_url A REDCap instance URL.
* @param string $project_api_token A REDCap project API token.
* @param bool $verbose Verbose mode.
*/
public function __construct(
- string $instance_api_url,
+ string $instance_url,
string $project_api_token,
bool $verbose = false
) {
- $this->_url = $instance_api_url;
+ $trimmed_url = rtrim($instance_url, '/');
+ $api_url = "{$trimmed_url}/api/";
+ $this->_url = $api_url;
$this->_token = $project_api_token;
- $this->_client = new Client($instance_api_url);
+ $this->_client = new Client($api_url);
$this->_verbose = $verbose;
}
@@ -301,6 +303,7 @@ class RedcapHttpClient
$eventData = array_map(fn($e) => new RedcapEvent($e), $events);
$this->_updateCache('event', $eventData);
}
+
return $this->_cache['event'];
}
@@ -316,6 +319,7 @@ class RedcapHttpClient
$data = [
'content' => 'instrument',
];
+
// update cache if first use
if (empty($this->_cache['instrument'])) {
$instruments = json_decode($this->_sendRequest($data), true);
@@ -399,46 +403,53 @@ class RedcapHttpClient
*
* Note: this method does not cache data.
*
- * @param string $recordId a record id
- * @param string $instrument an instrument/form name
- * @param ?string $event an event
- * @param ?string $repeatInstance a repeat instance index if any
+ * @param string $instrument_name A REDCap instrument name.
+ * @param ?string $unique_event_name A REDCap unique event name, if any.
+ * @param string $record_id A REDCap record ID.
+ * @param ?string $repeat_instance A repeat instance index, if any.
*
- * @return ?string a link if found, else null
+ * @return ?string The corresponding REDCap survery link if found, else null.
*/
public function getSurveyLink(
- string $recordId,
- string $instrument,
- ?string $event,
- ?string $repeatInstance
+ string $instrument_name,
+ ?string $unique_event_name,
+ string $record_id,
+ ?string $repeat_instance
): ?string {
- if (empty($instrument)) {
+ if (empty($instrument_name)) {
throw new \LorisException("[redcap] Error: 'instrument' null or empty.");
}
- if (empty($recordId)) {
- throw new \LorisException("[redcap] Error: 'recordId' null or empty.");
+
+ if (empty($record_id)) {
+ throw new \LorisException("[redcap] Error: 'record_id' null or empty.");
}
- $event = empty($event) ? null : $event;
- $repeatInstance = empty($repeatInstance) ? null : $repeatInstance;
+
+ $unique_event_name = empty($unique_event_name) ? null : $unique_event_name;
+ $repeat_instance = empty($repeat_instance) ? null : $repeat_instance;
// check mapping exists
- if (!$this->hasMappingInstrumentEvent($instrument, $event)) {
+ $mapping_instrument_event_exists = $this->hasMappingInstrumentEvent(
+ $instrument_name,
+ $unique_event_name
+ );
+
+ if (!$mapping_instrument_event_exists) {
throw new \LorisException(
- "[redcap] Error: mapping '$instrument'"
- ."/'$event' does not exist"
+ "[redcap] Error: mapping '$instrument_name'"
+ ."/'$unique_event_name' does not exist"
);
}
// data to send
$data = [
'content' => 'surveyLink',
- 'instrument' => $instrument,
- 'event' => $event,
- 'record' => $recordId,
+ 'instrument' => $instrument_name,
+ 'event' => $unique_event_name,
+ 'record' => $record_id,
];
- if ($repeatInstance !== null) {
- $data['repeat_instance'] = $repeatInstance;
+ if ($repeat_instance !== null) {
+ $data['repeat_instance'] = $repeat_instance;
}
// send request
@@ -448,19 +459,19 @@ class RedcapHttpClient
/**
* Get survey participants.
*
- * @param string $instrument_name The REDCap instrument name.
- * @param string $event_name The REDCap event name.
+ * @param string $instrument_name The REDCap instrument name.
+ * @param string $unique_event_name The REDCap unique event name.
*
* @return RedcapSurveyParticipant[] all events.
*/
public function getSurveyParticipants(
string $instrument_name,
- string $event_name,
+ string $unique_event_name,
): array {
$data = [
'content' => 'participantList',
'instrument' => $instrument_name,
- 'event' => $event_name,
+ 'event' => $unique_event_name,
];
$participants = json_decode($this->_sendRequest($data), true);
@@ -473,61 +484,61 @@ class RedcapHttpClient
/**
* Check if the instrument/event mapping exists.
*
- * @param string $instrument_name an instrument name
- * @param string $event_name an event name
+ * @param string $instrument_name A REDCap instrument name.
+ * @param string $unique_event_name A REDCap unique event name.
*
* @return bool true if couple is found, else false
*/
public function hasMappingInstrumentEvent(
string $instrument_name,
- string $event_name,
+ string $unique_event_name,
): bool {
$map = $this->getInstrumentEventMapping();
return $this->_hasMappingElement(
$map,
$instrument_name,
- $event_name,
+ $unique_event_name,
);
}
/**
* Check if the repeating-instrument/event mapping exists.
*
- * @param string $instrument_name an repeating instrument name
- * @param string $event_name an event name
+ * @param string $instrument_name A REDCap repeating instrument name.
+ * @param string $unique_event_name A REDCap unique event name.
*
* @return bool true if couple is found, else false
*/
public function hasRepeatingInstrumentEvent(
string $instrument_name,
- string $event_name,
+ string $unique_event_name,
): bool {
$map = $this->getRepeatingInstrumentsAndEvents();
return $this->_hasMappingElement(
$map,
$instrument_name,
- $event_name,
+ $unique_event_name,
);
}
/**
* Check if the instrument/event mapping exists in an arbitrary mapping array.
*
- * @param array $instrument_event_mappings a mapping array
- * @param string $instrument_name an instrument name
- * @param string $event_name an event name
+ * @param array $instrument_event_mappings A mapping array.
+ * @param string $instrument_name A REDCap instrument name.
+ * @param string $unique_event_name A REDCap unique event name.
*
* @return bool true if couple is found in the array, else false
*/
private function _hasMappingElement(
array &$instrument_event_mappings,
string $instrument_name,
- string $event_name
+ string $unique_event_name,
): bool {
return array_any(
$instrument_event_mappings,
fn($mapping) => (
- $mapping->getEventName() === $event_name
+ $mapping->getUniqueEventName() === $unique_event_name
&& $mapping->getFormName() === $instrument_name
)
);
@@ -538,19 +549,19 @@ class RedcapHttpClient
*
* Note: this method does not cache data.
*
- * @param string $recordId a record ID
+ * @param string $record_id a record ID
*
* @return ?string a link if found, else null
*/
- public function getSurveyQueueLink(string $recordId)
+ public function getSurveyQueueLink(string $record_id)
{
- if (empty($recordId)) {
- throw new \LorisException("[redcap] Error: 'recordId' null or empty.");
+ if (empty($record_id)) {
+ throw new \LorisException("[redcap] Error: 'record_id' null or empty.");
}
$data = [
'content' => 'surveyQueueLink',
- 'record' => $recordId
+ 'record' => $record_id
];
return $this->_sendRequest($data);
@@ -637,57 +648,66 @@ class RedcapHttpClient
/**
* Get all records for an single instrument.
*
- * @param string $instrument an instrument name
- * @param string $event an event name
- * @param string $recordId a record ID
- * @param bool $completedRecordsOnly only consider complete records?
+ * @param string $instrument_name A REDCap instrument name.
+ * @param string $unique_event_name A REDCap unique event name.
+ * @param string $record_id A REDCap record ID.
+ * @param bool $completed_records_only Only return completed records.
*
* @throws \LorisException
*
* @return IRedcapRecord[] an array of records
*/
public function getInstrumentRecord(
- string $instrument,
- string $event,
- string $recordId,
- bool $completedRecordsOnly = true
+ string $instrument_name,
+ string $unique_event_name,
+ string $record_id,
+ bool $completed_records_only = true
): array {
- if (empty($instrument)) {
- throw new \LorisException("[redcap] Error: required 'instrument'.");
+ if (empty($instrument_name)) {
+ throw new \LorisException(
+ "[redcap] Error: required 'instrument_name'."
+ );
}
- if (empty($event)) {
- throw new \LorisException("[redcap] Error: required 'event'.");
+
+ if (empty($unique_event_name)) {
+ throw new \LorisException(
+ "[redcap] Error: required 'unique_event_name'."
+ );
}
- if (empty($recordId)) {
- throw new \LorisException("[redcap] Error: required 'recordId'.");
+
+ if (empty($record_id)) {
+ throw new \LorisException("[redcap] Error: required 'record_id'.");
}
// mapping check
- if (!$this->hasMappingInstrumentEvent($instrument, $event)) {
+ $mapping_instrument_event_exists = $this->hasMappingInstrumentEvent(
+ $instrument_name,
+ $unique_event_name,
+ );
+
+ if (!$mapping_instrument_event_exists) {
throw new \LorisException(
- "[redcap] Error: mapping '$instrument'/"
- . "'$event' does not exist in REDCap"
+ "[redcap] Error: mapping '$instrument_name'/"
+ . "'$unique_event_name' does not exist in REDCap"
);
}
// request
- $r = $this->_getRecords(
- [$instrument],
- [$event],
- [$recordId]
+ $records = $this->getRecords(
+ [$instrument_name],
+ [$unique_event_name],
+ [$record_id]
);
- if (empty($r)) {
+ if (empty($records)) {
throw new \LorisException("[redcap] Error: no data found.");
}
// Only keep complete records
- if ($completedRecordsOnly) {
+ if ($completed_records_only) {
$completed = array_filter(
- $r,
- function ($record) use ($instrument) {
- return $record["{$instrument}_complete"] == 2;
- }
+ $records,
+ fn ($record) => $record["{$instrument_name}_complete"] == 2
);
if (count($completed) === 0) {
@@ -697,14 +717,14 @@ class RedcapHttpClient
}
} else {
// if not only completed records
- $completed = $r;
+ $completed = $records;
}
// Order the records by ${instrument_name}_dtt field value
usort(
$completed,
- function ($a, $b) use ($instrument) {
- $dttField = "{$instrument}_dtt";
+ function ($a, $b) use ($instrument_name) {
+ $dttField = "{$instrument_name}_dtt";
$a_date = new \DateTimeImmutable($a[$dttField]);
$b_date = new \DateTimeImmutable($b[$dttField]);
return $a_date <=> $b_date;
@@ -714,19 +734,23 @@ class RedcapHttpClient
// is a repeating instrument?
$final = [];
if ($this->getProjectInfo()->has_repeating_instruments
- && $this->hasRepeatingInstrumentEvent($instrument, $event)
+ && $this->hasRepeatingInstrumentEvent(
+ $instrument_name,
+ $unique_event_name
+ )
) {
foreach ($completed as $index => $record) {
$final[] = new RedcapRepeatingRecord(
- $instrument,
+ $instrument_name,
$record,
$index + 1
);
}
} else {
// return the only record
- $final[] = new RedcapRecord($instrument, $completed[0]);
+ $final[] = new RedcapRecord($instrument_name, $completed[0]);
}
+
return $final;
}
@@ -735,19 +759,22 @@ class RedcapHttpClient
* Cannot return all values. At least one of the three parameters needs to
* be filled with one value, else an exception will be generated.
*
- * @param array $instruments instrument names
- * @param array $events event names
- * @param array $records records IDs
+ * @param array $instrument_names A list of REDCap instrument names.
+ * @param array $unique_event_names A list of REDCap unique event names.
+ * @param array $record_ids A list of REDCap records IDs.
*
* @return array an array of records (array of [field name => field values])
*/
- private function _getRecords(
- array $instruments = [],
- array $events = [],
- array $records = []
+ public function getRecords(
+ array $instrument_names = [],
+ array $unique_event_names = [],
+ array $record_ids = []
): array {
// security, do not get all records
- if (empty($instruments) && empty($events) && empty($records)) {
+ if (empty($instrument_names)
+ && empty($unique_event_names)
+ && empty($record_ids)
+ ) {
throw new \LorisException(
"[redcap] Error: get all recrods forbidden without arguments."
);
@@ -759,10 +786,12 @@ class RedcapHttpClient
'action' => 'export',
'type' => 'flat',
'csvDelimiter' => '',
- 'forms' => $instruments,
- 'fields' => [],
- 'events' => $events,
- 'records' => $records,
+ 'forms' => $instrument_names,
+ // The 'record_id' parameter adds both the 'record_id' and
+ // 'redcap_event_name' fields to the REDCap records.
+ 'fields' => ['record_id'],
+ 'events' => $unique_event_names,
+ 'records' => $record_ids,
'rawOrLabel' => 'raw',
'rawOrLabelHeaders' => 'raw',
'exportCheckboxLabel' => true,
diff --git a/modules/redcap/php/config/redcapconfig.class.inc b/modules/redcap/php/config/redcapconfig.class.inc
index 5ef6ade292a..ce737a39838 100644
--- a/modules/redcap/php/config/redcapconfig.class.inc
+++ b/modules/redcap/php/config/redcapconfig.class.inc
@@ -21,13 +21,6 @@ namespace LORIS\redcap\config;
*/
class RedcapConfig
{
- /**
- * The LORIS user that is assigned the issues created by the REDCap module.
- *
- * @var \User
- */
- public readonly \User $issue_assignee;
-
/**
* The REDCap project ID of the project.
*
@@ -81,9 +74,6 @@ class RedcapConfig
/**
* Constructor.
*
- * @param \User $issue_assignee The LORIS issue
- * assignee for the
- * REDCap module.
* @param string $redcap_project_id The REDCap project
* ID.
* @param string $redcap_instance_url The REDCap instance
@@ -103,7 +93,6 @@ class RedcapConfig
* mappings.
*/
public function __construct(
- \User $issue_assignee,
string $redcap_project_id,
string $redcap_instance_url,
string $redcap_api_token,
@@ -112,10 +101,9 @@ class RedcapConfig
RedcapConfigLorisId $candidate_id,
?array $visits,
) {
- $this->issue_assignee = $issue_assignee;
- $this->redcap_project_id = $redcap_project_id;
- $this->redcap_instance_url = $redcap_instance_url;
- $this->redcap_api_token = $redcap_api_token;
+ $this->redcap_project_id = $redcap_project_id;
+ $this->redcap_instance_url = $redcap_instance_url;
+ $this->redcap_api_token = $redcap_api_token;
$this->prefix_instrument_variable = $prefix_instrument_variable;
$this->redcap_participant_id = $redcap_participant_id;
$this->candidate_id = $candidate_id;
diff --git a/modules/redcap/php/config/redcapconfigcreatesession.class.inc b/modules/redcap/php/config/redcapconfigcreatesession.class.inc
new file mode 100644
index 00000000000..afcd0783dd6
--- /dev/null
+++ b/modules/redcap/php/config/redcapconfigcreatesession.class.inc
@@ -0,0 +1,62 @@
+site_name = $site_name;
+ $this->project_name = $project_name;
+ $this->cohort_name = $cohort_name;
+ }
+}
diff --git a/modules/redcap/php/config/redcapconfigparser.class.inc b/modules/redcap/php/config/redcapconfigparser.class.inc
index ec0bfa1f0bd..a06a31c2aa0 100644
--- a/modules/redcap/php/config/redcapconfigparser.class.inc
+++ b/modules/redcap/php/config/redcapconfigparser.class.inc
@@ -35,13 +35,6 @@ class RedcapConfigParser
*/
private \LORIS\LorisInstance $_loris;
- /**
- * The LORIS database.
- *
- * @var \Database
- */
- private \Database $_db;
-
/**
* The REDCap instance URL.
*
@@ -69,7 +62,6 @@ class RedcapConfigParser
string $redcap_project_id,
) {
$this->_loris = $loris;
- $this->_db = $loris->getDatabaseConnection();
$this->_redcap_instance_url = $redcap_instance_url;
$this->_redcap_project_id = $redcap_project_id;
}
@@ -104,8 +96,6 @@ class RedcapConfigParser
*/
private function _parseRedcapNode(array $redcap_node): ?RedcapConfig
{
- $issue_assignee = $this->_getIssueAssignee();
-
$instance_node = $this->_getInstanceNode($redcap_node);
if ($instance_node === null) {
throw $this->_exception(
@@ -131,7 +121,6 @@ class RedcapConfigParser
$visits = $this->_parseVisits($project_node);
return new RedcapConfig(
- $issue_assignee,
$this->_redcap_project_id,
$this->_redcap_instance_url,
$redcap_api_token,
@@ -142,55 +131,6 @@ class RedcapConfigParser
);
}
- /**
- * Get the REDCap issue assignee from configuration module.
- *
- * @return \User The REDCap issue assignee user.
- */
- private function _getIssueAssignee(): \User
- {
- $assignee_user_id = $this->_db->pselectOne(
- "SELECT c.Value
- FROM Config c
- JOIN ConfigSettings cs ON (cs.ID = c.ConfigID)
- WHERE cs.Name = 'redcap_issue_assignee'
- ",
- []
- );
-
- if (empty($assignee_user_id)) {
- throw $this->_exception(
- "no REDCap issue assignee in configuration, missing"
- . " 'redcap_issue_assignee'."
- );
- }
-
- $assignee = $this->_db->pselectOne(
- "SELECT DISTINCT u.userID
- FROM users u
- JOIN user_perm_rel upr ON (upr.userid = u.id)
- JOIN permissions p ON (p.permid = upr.permid)
- WHERE u.userID = :usid
- AND u.Active = 'Y'
- AND u.Pending_approval = 'N'
- AND (
- p.code = 'issue_tracker_developer'
- OR p.code = 'superuser'
- )
- ",
- ['usid' => $assignee_user_id]
- );
-
- if ($assignee === null) {
- throw $this->_exception(
- "REDCap issue assignee '$assignee_user_id' does not exist or does"
- . " not have enough privileges."
- );
- }
-
- return \User::factory($assignee_user_id);
- }
-
/**
* Parse the REDCap configuration instance XML node from a REDCap configuration
* XML node.
@@ -426,11 +366,60 @@ class RedcapConfigParser
$redcap_arm_name = $visit_node['redcap-arm-name'] ?? null;
$redcap_event_name = $visit_node['redcap-event-name'] ?? null;
+ $create_session = $this->_parseCreateSession($visit_node);
return new RedcapConfigVisit(
$visit_label,
$redcap_arm_name,
$redcap_event_name,
+ $create_session,
+ );
+ }
+
+ /**
+ * Parse a REDCap session creation configuration from a REDCap session creation
+ * configuration XML node.
+ *
+ * @param array $visit_node The REDCap configuration visit mapping XML node.
+ *
+ * @return ?RedcapConfigCreateSession The REDCap session creation configuration.
+ */
+ private function _parseCreateSession(
+ array $visit_node,
+ ): ?RedcapConfigCreateSession {
+ $create_session_node = $visit_node['create-session'] ?? null;
+ if (empty($create_session_node)) {
+ return null;
+ }
+
+ $site_name = $create_session_node['site-name'] ?? null;
+ if (empty($site_name)) {
+ throw $this->_exception(
+ "no site name in session creation configuration, missing node"
+ . " 'site-name'."
+ );
+ }
+
+ $project_name = $create_session_node['project-name'] ?? null;
+ if (empty($project_name)) {
+ throw $this->_exception(
+ "no project name in session creation configuration, missing node"
+ . " 'project-name'."
+ );
+ }
+
+ $cohort_name = $create_session_node['cohort-name'] ?? null;
+ if (empty($cohort_name)) {
+ throw $this->_exception(
+ "no cohort name in session creation configuration, missing node"
+ . " 'cohort-name'."
+ );
+ }
+
+ return new RedcapConfigCreateSession(
+ $site_name,
+ $project_name,
+ $cohort_name,
);
}
diff --git a/modules/redcap/php/config/redcapconfigvisit.class.inc b/modules/redcap/php/config/redcapconfigvisit.class.inc
index 44c53e026c4..1930d750ae1 100644
--- a/modules/redcap/php/config/redcapconfigvisit.class.inc
+++ b/modules/redcap/php/config/redcapconfigvisit.class.inc
@@ -30,33 +30,45 @@ class RedcapConfigVisit
public readonly string $visit_label;
/**
- * The REDCap arm name of the mapping, or `NULL`.
+ * The REDCap arm name of the mapping, if any.
*
* @var ?string
*/
public readonly ?string $redcap_arm_name;
/**
- * The REDCap event name of the mapping, or `NULL`.
+ * The REDCap event name of the mapping, if any.
*
* @var ?string
*/
public readonly ?string $redcap_event_name;
+ /**
+ * The LORIS automatic session creation configuration, if any.
+ *
+ * @var ?RedcapConfigCreateSession
+ */
+ public ?RedcapConfigCreateSession $create_session;
+
/**
* Constructor.
*
- * @param string $visit_label The LORIS visit label.
- * @param ?string $redcap_arm_name The REDCap arm name.
- * @param ?string $redcap_event_name The REDCap event name.
+ * @param string $visit_label The LORIS visit label.
+ * @param ?string $redcap_arm_name The REDCap arm name.
+ * @param ?string $redcap_event_name The REDCap event name.
+ * @param ?RedcapConfigCreateSession $create_session The LORIS automatic
+ * session creation
+ * configuration.
*/
public function __construct(
string $visit_label,
?string $redcap_arm_name,
?string $redcap_event_name,
+ ?RedcapConfigCreateSession $create_session,
) {
$this->visit_label = $visit_label;
$this->redcap_arm_name = $redcap_arm_name;
$this->redcap_event_name = $redcap_event_name;
+ $this->create_session = $create_session;
}
}
diff --git a/modules/redcap/php/endpoints/notifications.class.inc b/modules/redcap/php/endpoints/notifications.class.inc
index f19cc7a0ccb..04a12d2f7c9 100644
--- a/modules/redcap/php/endpoints/notifications.class.inc
+++ b/modules/redcap/php/endpoints/notifications.class.inc
@@ -16,6 +16,7 @@ use \Psr\Http\Message\ServerRequestInterface;
use \Psr\Http\Message\ResponseInterface;
use \LORIS\Http\Endpoint;
use \LORIS\redcap\RedcapNotificationHandler;
+use \LORIS\redcap\RedcapQueries;
use \LORIS\redcap\config\RedcapConfig;
use \LORIS\redcap\config\RedcapConfigParser;
use \LORIS\redcap\client\RedcapHttpClient;
@@ -97,7 +98,8 @@ class Notifications extends Endpoint
*/
private function _handlePOST(ServerRequestInterface $request): ResponseInterface
{
- $db = $this->loris->getDatabaseConnection();
+ $db = $this->loris->getDatabaseConnection();
+ $queries = new RedcapQueries($this->loris);
// Try url-encoded first
$data = $request->getParsedBody();
@@ -109,26 +111,27 @@ class Notifications extends Endpoint
try {
$received_datetime = new \DateTimeImmutable();
- $notification = new RedcapNotification($data, $received_datetime);
+ $redcap_notif = new RedcapNotification($data, $received_datetime);
+
+ $this->_issue_assignee = $queries->getRedcapIssueAssignee();
$config_parser = new RedcapConfigParser(
$this->loris,
- $notification->instance_url,
- $notification->project_id,
+ $redcap_notif->instance_url,
+ $redcap_notif->project_id,
);
$config = $config_parser->parse();
- $this->_issue_assignee = $config->issue_assignee;
// should the notification be ignored?
- if ($this->_ignoreNotification($notification, $config)) {
+ if ($this->_ignoreNotification($redcap_notif, $config)) {
return new \LORIS\Http\Response();
}
// Add to the database
$db->insert(
'redcap_notification',
- $notification->toDatabaseArray()
+ $redcap_notif->toDatabaseArray()
);
} catch (\UnexpectedValueException $e) {
$body = (string) $request->getBody();
@@ -159,15 +162,15 @@ class Notifications extends Endpoint
// get a new redcap client based on the notification info
try {
$redcap_client = new RedcapHttpClient(
- "{$config->redcap_instance_url}/api/",
+ $config->redcap_instance_url,
$config->redcap_api_token,
);
$notification_handler = new RedcapNotificationHandler(
$this->loris,
- $config,
$redcap_client,
- $notification,
+ $redcap_notif,
+ $config,
);
} catch (\LorisException $le) {
$this->_createIssue(
@@ -182,9 +185,9 @@ class Notifications extends Endpoint
$notification_handler->handle();
} catch (\DatabaseException $e) {
$rec = "[redcap] Error: "
- . "PSCID: " . $notification->record_id
- . "Visit: " . $notification->event_name
- . "instrument: " . $notification->instrument_name;
+ . "PSCID: " . $redcap_notif->record_id
+ . "Visit: " . $redcap_notif->unique_event_name
+ . "instrument: " . $redcap_notif->instrument_name;
$this->_createIssue(
'Instrument data not updated - Database exception',
$e->getMessage(),
@@ -193,9 +196,9 @@ class Notifications extends Endpoint
return new \LORIS\Http\Response\JSON\InternalServerError();
} catch (\DomainException $e) {
$rec = "[redcap] Error: "
- . "PSCID: " . $notification->record_id
- . "Visit: " . $notification->event_name
- . "instrument: " . $notification->instrument_name;
+ . "PSCID: " . $redcap_notif->record_id
+ . "Visit: " . $redcap_notif->unique_event_name
+ . "instrument: " . $redcap_notif->instrument_name;
$this->_createIssue(
'Instrument data not updated - Domain exception',
$e->getMessage(),
@@ -206,14 +209,14 @@ class Notifications extends Endpoint
$this->_createIssue(
'Instrument data not updated - Configuration/Permission exception',
$ce->getMessage(),
- json_encode($notification->toDatabaseArray())
+ json_encode($redcap_notif->toDatabaseArray())
);
return new \LORIS\Http\Response\JSON\InternalServerError();
} catch (\Throwable $e) {
$this->_createIssue(
'Instrument data not updated',
$e->getMessage(),
- json_encode($notification->toDatabaseArray())
+ json_encode($redcap_notif->toDatabaseArray())
);
return new \LORIS\Http\Response\JSON\InternalServerError();
}
@@ -226,18 +229,18 @@ class Notifications extends Endpoint
* Ignored notifications will not trigger any issue creation.
* Optionally prints an error in log on a case by case basis.
*
- * @param RedcapNotification $notif The REDCap notification.
- * @param ?RedcapConfig $redcap_config The REDCap module configuration.
+ * @param RedcapNotification $redcap_notif The REDCap notification.
+ * @param ?RedcapConfig $config The REDCap module configuration.
*
* @return bool if the notification should be ignored, else false.
*/
private function _ignoreNotification(
- RedcapNotification $notif,
- ?RedcapConfig $redcap_config,
+ RedcapNotification $redcap_notif,
+ ?RedcapConfig $config,
): bool {
- $notif_data = json_encode($notif->toDatabaseArray());
+ $notif_data = json_encode($redcap_notif->toDatabaseArray());
- if ($redcap_config === null) {
+ if ($config === null) {
error_log(
"[redcap][notification:skip] unknown source/project: $notif_data"
);
@@ -249,7 +252,7 @@ class Notifications extends Endpoint
$authInstr = $config->getSetting('redcap_importable_instrument');
// ignore instruments that are not in the authorized list
- if (!in_array($notif->instrument_name, $authInstr, true)) {
+ if (!in_array($redcap_notif->instrument_name, $authInstr, true)) {
error_log(
"[redcap][notification:skip] unauthorized instrument: $notif_data"
);
@@ -257,7 +260,7 @@ class Notifications extends Endpoint
}
// ignore notifications that are not 'complete'
- if (!$notif->isComplete()) {
+ if (!$redcap_notif->isComplete()) {
error_log(
"[redcap][notification:skip] instrument not complete: $notif_data"
);
diff --git a/modules/redcap/php/redcapmapper.class.inc b/modules/redcap/php/redcapmapper.class.inc
new file mode 100644
index 00000000000..bf5ff061f8e
--- /dev/null
+++ b/modules/redcap/php/redcapmapper.class.inc
@@ -0,0 +1,465 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+
+namespace LORIS\redcap;
+
+use \LORIS\LorisInstance;
+use \LORIS\redcap\RedcapQueries;
+use \LORIS\redcap\config\RedcapConfig;
+use \LORIS\redcap\config\RedcapConfigLorisId;
+use \LORIS\redcap\config\RedcapConfigRedcapId;
+use \LORIS\redcap\config\RedcapConfigVisit;
+use \LORIS\redcap\client\RedcapHttpClient;
+use \LORIS\redcap\client\models\RedcapEvent;
+use \LORIS\redcap\client\models\records\RedcapRecord;
+
+/**
+ * Mapping methods to match REDCap and LORIS identifiers in the REDCap module.
+ *
+ * PHP Version 8
+ *
+ * @category REDCap
+ * @package Main
+ * @author Regis Ongaro-Carcy
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class RedcapMapper
+{
+ /**
+ * THe LORIS instance.
+ *
+ * @var LorisInstance
+ */
+ private LorisInstance $_loris;
+
+ /**
+ * The REDCap HTTP client.
+ *
+ * @var RedcapHttpClient
+ */
+ private RedcapHttpClient $_redcap_client;
+
+ /**
+ * The REDCap module configuration.
+ *
+ * @var RedcapConfig
+ */
+ private RedcapConfig $_config;
+
+ /**
+ * The REDCap module database queries.
+ *
+ * @var RedcapQueries
+ */
+ private RedcapQueries $_queries;
+
+ /**
+ * Constructor.
+ *
+ * @param LorisInstance $loris The LORIS instance.
+ * @param RedcapHttpClient $redcap_client The REDCap HTTP client.
+ * @param RedcapConfig $config The REDCap module configuration.
+ */
+ public function __construct(
+ LorisInstance $loris,
+ RedcapHttpClient $redcap_client,
+ RedcapConfig $config,
+ ) {
+ $this->_loris = $loris;
+ $this->_redcap_client = $redcap_client;
+ $this->_config = $config;
+ $this->_queries = new RedcapQueries($loris);
+ }
+
+ /**
+ * Get the visit label of the session associated with a REDCap record. If the
+ * session does not already exist, this function either creates it if automatic
+ * session creation is enabled for that visit in the REDCap module
+ * configuration, or throws an error if it is not.
+ *
+ * @param RedcapRecord $redcap_record The REDCap record.
+ * @param string $unique_event_name The unique event name associated with
+ * that REDCap record.
+ * @param \Candidate $candidate The LORIS candidate associated with
+ * that REDCap record.
+ *
+ * @return string The visit label of the relevant session.
+ */
+ public function getVisitLabel(
+ RedcapRecord $redcap_record,
+ string $unique_event_name,
+ \Candidate $candidate,
+ ): string {
+ $visit_config = $this->getVisitConfig($unique_event_name);
+
+ // If no visit mappings are defined in the configuration, use the REDCap
+ // unique event name as the visit label directly.
+ if ($visit_config === null) {
+ return $unique_event_name;
+ }
+
+ $this->checkOrCreateSession($redcap_record, $visit_config, $candidate);
+
+ return $visit_config->visit_label;
+ }
+
+ /**
+ * Get the LORIS visit label associated with a REDCap unique event name
+ * based on the REDCap module configuration.
+ *
+ * @param string $unique_event_name The REDCap unique event name.
+ *
+ * @return ?RedcapConfigVisit The LORIS visit label associated with the REDCap
+ * notification, or `null` if no corresponding visit label is
+ * found.
+ */
+ public function getVisitConfig(string $unique_event_name): ?RedcapConfigVisit
+ {
+ // Get the list of all the REDCap events for this REDCap project.
+ $redcap_events = $this->_redcap_client->getEvents();
+
+ // Find the REDCap event that matches the REDCap notification event.
+ $redcap_event = array_find(
+ $redcap_events,
+ fn($redcap_event) => $redcap_event->unique_name === $unique_event_name,
+ );
+
+ // There should always be a REDCap event that matches the REDCap
+ // notification event, but since this is an external API, check this
+ // assumption nonetheless.
+ if ($redcap_event === null) {
+ throw new \LorisException(
+ "[redcap] Error: No REDCap event found for unique event name"
+ . " '{$unique_event_name}'."
+ );
+ }
+
+ // If there are no visit mappings in the REDCap module configuration, simply
+ // use the REDCap event name as the LORIS vist name.
+ if ($this->_config->visits === null) {
+ return null;
+ }
+
+ $event_name = $redcap_event->name;
+ $arm_name = $this->_getRedcapEventArmName($redcap_event);
+
+ // Look for the REDCap module configuration visit mappings that match the
+ // REDCap notification event, using the event name and arm name.
+ $visit_mappings = array_filter(
+ $this->_config->visits,
+ function ($visit_config) use ($event_name, $arm_name) {
+ if ($visit_config->redcap_event_name !== null
+ && $visit_config->redcap_event_name !== $event_name
+ ) {
+ return false;
+ }
+
+ if ($visit_config->redcap_arm_name !== null
+ && $visit_config->redcap_arm_name !== $arm_name
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+ );
+
+ // If there is no visit mapping that match the REDCap notification event,
+ // return `null` and ignore this notification.
+ if (count($visit_mappings) === 0) {
+ return null;
+ }
+
+ // If there are several visit mappings that match the REDCap notification
+ // event, raise an error.
+ if (count($visit_mappings) !== 1) {
+ throw new \LorisException(
+ "[redcap] Error: Multiple visits selectable for event name"
+ . " '$event_name' and arm name '$arm_name'."
+ );
+ }
+
+ // Return the LORIS visit label associated with the matching visit mapping.
+ $visit_mapping = reset($visit_mappings);
+ return $visit_mapping ? $visit_mapping : null;
+ }
+
+ /**
+ * Check that a session exists or create it using the REDCap module automatic
+ * session creation configuration. Throw an exception if the session does not
+ * exist and automatic session creation is not enabled for that visit.
+ *
+ * @param RedcapRecord $redcap_record The REDCap record.
+ * @param RedcapConfigVisit $visit_mapping The REDCap module visit
+ * configuration associated with that
+ * REDCap record.
+ * @param \Candidate $candidate The LORIS candidate associated with
+ * that REDCap record.
+ *
+ * @return void
+ */
+ public function checkOrCreateSession(
+ RedcapRecord $redcap_record,
+ RedcapConfigVisit $visit_mapping,
+ \Candidate $candidate,
+ ) {
+ // Get the ID of the session associated with the candidate and visit label.
+ $session_id = array_search(
+ $visit_mapping->visit_label,
+ $candidate->getListOfVisitLabels()
+ );
+
+ // If the session already exists, no need to create it.
+ if ($session_id) {
+ return;
+ }
+
+ // Throw an exception if the session does not exist and there is no
+ // session creation configuration for that visit, hence skipping the
+ // notification.
+ if ($visit_mapping->create_session === null) {
+ $psc_id = $candidate->getPSCID();
+ $visit_label = $visit_mapping->visit_label;
+ throw new \LorisException(
+ "[redcap] No session found for candidate '$psc_id' and visit label"
+ . " '$visit_label', skipping notification."
+ );
+ }
+
+ // Get the LORIS site, project, and cohort from the database using the
+ // information provided by the session creation configuration.
+
+ $site_name = $visit_mapping->create_session->site_name;
+ $site_id = array_search($site_name, \Utility::getSiteList());
+
+ $project_name = $visit_mapping->create_session->project_name;
+ $project_id = array_search($project_name, \Utility::getProjectList());
+
+ $cohort_name = $visit_mapping->create_session->cohort_name;
+ $cohort_id = array_search($cohort_name, \Utility::getCohortList());
+
+ // Throw an exception if any of the site, project, or cohort could not be
+ // obtained from the database, which means that the information present in
+ // the session creation configuration was incorrect.
+
+ if (!$site_id) {
+ throw new \LorisException(
+ "[redcap] Error: No LORIS site found for site name '$site_name',"
+ . " the REDCap module configuration is incorrect."
+ );
+ }
+
+ if (!$project_id) {
+ throw new \LorisException(
+ "[redcap] Error: No LORIS project found for site name"
+ . " '$project_name', the REDCap module configuration is incorrect."
+ );
+ }
+
+ if (!$cohort_id) {
+ throw new \LorisException(
+ "[redcap] Error: No LORIS cohort found for site name"
+ . " '$cohort_name', the REDCap module configuration is incorrect."
+ );
+ }
+
+ // Since the REDCap module is called by the REDCap API or from a script,
+ // there is usually no logged in user. As such, manually set the user to be
+ // the REDCap issue assignee to create the session.
+
+ $user = $this->_queries->getRedcapIssueAssignee();
+
+ $state = \State::singleton();
+ $state->setUsername($user->getUsername());
+ $_SESSION['State'] =& $state;
+
+ // Create the session that corresponds to this mapping.
+ \TimePoint::createNew(
+ $candidate,
+ $cohort_id,
+ $visit_mapping->visit_label,
+ \Site::singleton(new \CenterID(strval($site_id))),
+ \Project::getProjectFromID(new \ProjectID(strval($project_id))),
+ );
+
+ // Get the session again now that it been created.
+
+ $session_id = array_search(
+ $visit_mapping->visit_label,
+ $candidate->getListOfVisitLabels()
+ );
+
+ $session = \TimePoint::singleton(new \SessionID(strval($session_id)));
+
+ // Start the session next stage, so that it can receive instrument data.
+
+ $new_stage = $session->getNextStage();
+ $session->startStage($new_stage);
+ $session->setData(
+ [
+ "Date_{$new_stage}" => $redcap_record->datetime->format('Y-m-d'),
+ ]
+ );
+
+ // Add the test batteries to the session.
+
+ $battery = new \NDB_BVL_Battery;
+
+ $first_visit_label = $candidate->getFirstVisit();
+ if ($first_visit_label == $session->getVisitLabel()) {
+ $first_visit = true;
+ } else {
+ $first_visit = false;
+ }
+
+ $battery->selectBattery($session->getSessionID());
+
+ // add instruments to the time point (lower case stage)
+ $battery->createBattery(
+ $this->_loris,
+ $session->getCohortID(),
+ $new_stage,
+ $session->getVisitLabel(),
+ $session->getCenterID(),
+ $first_visit,
+ );
+ }
+
+ /**
+ * Get the LORIS candidate identifier (CandID or PSCID) that matches a REDCap
+ * record ID, instrument name, and unique event named based on the REDCap module
+ * configuration.
+ *
+ * @param string $record_id The REDCap record ID.
+ * @param string $instrument_name The REDCap instrument name.
+ * @param string $unique_event_name The REDCap unique event name.
+ *
+ * @return string The LORIS candidate identifier.
+ */
+ public function getCandidateIdentifier(
+ string $record_id,
+ string $instrument_name,
+ string $unique_event_name,
+ ): string {
+ // If the REDCap module configuration is to use the REDCap record ID, simply
+ // return the REDCap notification record ID.
+ $condition = $this->_config->redcap_participant_id
+ === RedcapConfigRedcapId::RecordId;
+
+ if ($condition) {
+ return $record_id;
+ }
+
+ // Get the list of all REDCap survey participants for the notification
+ // instrument and event.
+ $participants = $this->_redcap_client->getSurveyParticipants(
+ $instrument_name,
+ $unique_event_name,
+ );
+
+ // Find the survey participant matching the notification record.
+ $participant = array_find(
+ $participants,
+ fn($participant) => $participant->record === $record_id
+ );
+
+ // If no survey participant is found for that record, raise an error. This
+ // can happen because even with surveys enabled in REDCap, there is no
+ // requirement for all records to be linked to survey participants.
+ if ($participant === null) {
+ throw new \LorisException(
+ "[redcap] Error: No survey participant found for record ID"
+ . " '$record_id'."
+ );
+ }
+
+ // If the REDCap survey participant does not have a custom identifier, raise
+ // an error. This can happen because the survey participant identifier must
+ // be set manually in REDCap and is optional.
+ if ($participant->identifier === null) {
+ throw new \LorisException(
+ "[redcap] Error: Survey participant has no identifier for"
+ . " record ID '$record_id'."
+ );
+ }
+
+ // Return the identifier of the survey participant.
+ return $participant->identifier;
+ }
+
+ /**
+ * Get the LORIS candidate that matches a candidate identifier (CandID or PSCID)
+ * based on the REDCap module configuration.
+ *
+ * @param string $candidate_identifier The candidate identifier obtained from
+ * REDCap.
+ *
+ * @return \Candidate The LORIS candidate.
+ */
+ public function getCandidateWithIdentifier(
+ string $candidate_identifier,
+ ): \Candidate {
+ // Get the candidate using the REDCap participant identifier as a CandID or
+ // PSCID depending on the REDCap module configuration.
+ $candidate = match ($this->_config->candidate_id) {
+ RedcapConfigLorisId::CandId =>
+ $this->_queries->tryGetCandidateWithCandId($candidate_identifier),
+ RedcapConfigLorisId::PscId =>
+ $this->_queries->tryGetCandidateWithPscId($candidate_identifier),
+ };
+
+ // If no candidate matches the REDCap participant identifier, raise an
+ // error.
+ if ($candidate === null) {
+ throw new \LorisException(
+ "[redcap] Error: No LORIS candidate found for candidate identifier"
+ . " '$candidate_identifier'."
+ );
+ }
+
+ // Return the candidate.
+ return $candidate;
+ }
+
+ /**
+ * Get the REDCap arm name of a REDCap event.
+ *
+ * @param RedcapEvent $redcap_event The REDCap event.
+ *
+ * @return string The REDCap event arm name.
+ */
+ private function _getRedcapEventArmName(RedcapEvent $redcap_event): string
+ {
+ // Get the list of all the REDCap arms for this REDCap project.
+ $redcap_arms = $this->_redcap_client->getArms();
+
+ // Find the REDCap arm that matches the REDCap event.
+ $redcap_arm = array_find(
+ $redcap_arms,
+ fn($redcap_arm) => $redcap_arm->number === $redcap_event->arm_number,
+ );
+
+ // There should always be a REDCap arm that matches the REDCap event arm,
+ // but since this is an external API, check this
+ // assumption nonetheless.
+ if ($redcap_arm === null) {
+ throw new \LorisException(
+ "[redcap] Error: No REDCap arm found for event arm number"
+ . " '{$redcap_event->arm_number}'."
+ );
+ }
+
+ // Return the name of the REDCap arm.
+ return $redcap_arm->name;
+ }
+}
diff --git a/modules/redcap/php/redcapnotificationhandler.class.inc b/modules/redcap/php/redcapnotificationhandler.class.inc
index bd0549ba49e..c5e3a817f0a 100644
--- a/modules/redcap/php/redcapnotificationhandler.class.inc
+++ b/modules/redcap/php/redcapnotificationhandler.class.inc
@@ -13,13 +13,10 @@
namespace LORIS\redcap;
use \LORIS\LorisInstance;
-use \LORIS\redcap\Queries;
+use \LORIS\redcap\RedcapQueries;
use \LORIS\redcap\client\RedcapHttpClient;
use \LORIS\redcap\config\RedcapConfig;
-use \LORIS\redcap\config\RedcapConfigLorisId;
-use \LORIS\redcap\config\RedcapConfigRedcapId;
use \LORIS\redcap\client\models\records\IRedcapRecord;
-use \LORIS\redcap\client\models\RedcapEvent;
use \LORIS\redcap\client\models\RedcapNotification;
/**
@@ -53,52 +50,63 @@ class RedcapNotificationHandler
private LorisInstance $_loris;
/**
- * The database queries used by this pipeline.
+ * The REDCap HTTP client.
*
- * @var Queries
+ * @var RedcapHttpClient
*/
- private Queries $_queries;
+ private RedcapHttpClient $_redcap_client;
+
+ /**
+ * The REDCap notification.
+ *
+ * @var RedcapNotification
+ */
+ private RedcapNotification $_redcap_notif;
/**
* The REDCap module configuration.
*
* @var RedcapConfig
*/
- private RedcapConfig $_redcap_config;
+ private RedcapConfig $_config;
/**
- * The REDCap HTTP client.
+ * The REDCap mapper.
*
- * @var RedcapHttpClient
+ * @var RedcapMapper
*/
- private RedcapHttpClient $_redcap_client;
+ private RedcapMapper $_mapper;
/**
- * The REDCap notification.
+ * The REDCap module database queries.
*
- * @var RedcapNotification
+ * @var RedcapQueries
*/
- private RedcapNotification $_redcap_notif;
+ private RedcapQueries $_queries;
/**
- * Contructor.
+ * Constructor.
*
- * @param LorisInstance $loris A LORIS instance.
- * @param RedcapConfig $redcap_config A REDCap module configuration.
- * @param RedcapHttpClient $redcap_client A REDCap HTTP client.
- * @param RedcapNotification $redcap_notif A REDCap notification.
+ * @param LorisInstance $loris The LORIS instance.
+ * @param RedcapHttpClient $redcap_client The REDCap HTTP client.
+ * @param RedcapNotification $redcap_notif The REDCap notification.
+ * @param RedcapConfig $config The REDCap module configuration.
*/
public function __construct(
LorisInstance $loris,
- RedcapConfig $redcap_config,
RedcapHttpClient $redcap_client,
RedcapNotification $redcap_notif,
+ RedcapConfig $config,
) {
+ $queries = new RedcapQueries($loris);
+ $mapper = new RedcapMapper($loris, $redcap_client, $config);
+
$this->_loris = $loris;
- $this->_queries = new Queries($loris);
- $this->_redcap_config = $redcap_config;
$this->_redcap_client = $redcap_client;
$this->_redcap_notif = $redcap_notif;
+ $this->_config = $config;
+ $this->_mapper = $mapper;
+ $this->_queries = $queries;
}
/**
@@ -121,7 +129,7 @@ class RedcapNotificationHandler
// get data from redcap
$records = $this->_redcap_client->getInstrumentRecord(
$this->_redcap_notif->instrument_name,
- $this->_redcap_notif->event_name,
+ $this->_redcap_notif->unique_event_name,
$this->_redcap_notif->record_id,
true,
);
@@ -129,7 +137,7 @@ class RedcapNotificationHandler
$this->_queries->markRedcapNotifAsHandled(
$this->_redcap_notif->project_id,
$this->_redcap_notif->record_id,
- $this->_redcap_notif->event_name,
+ $this->_redcap_notif->unique_event_name,
$this->_redcap_notif->instrument_name,
$this->_redcap_notif->received_datetime,
new \DateTimeImmutable(),
@@ -138,63 +146,43 @@ class RedcapNotificationHandler
$this->_queries->releaseNotificationLock();
}
- $participant_id = $this->_getRedcapParticipantId();
+ $candidate_identifier = $this->_mapper->getCandidateIdentifier(
+ $this->_redcap_notif->record_id,
+ $this->_redcap_notif->instrument_name,
+ $this->_redcap_notif->unique_event_name,
+ );
- $candidate = $this->_getCandidateWithRedcapParticipantId($participant_id);
- $psc_id = $candidate->getPSCID();
+ $candidate = $this->_mapper->getCandidateWithIdentifier(
+ $candidate_identifier
+ );
- $visit_label = $this->_getVisitLabel();
+ $psc_id = $candidate->getPSCID();
// Track which instruments are updated and not updated.
$instruments_not_updated = [];
- //
+ $record_importer = new RedcapRecordImporter(
+ $this->_loris,
+ $this->_redcap_client,
+ $this->_config,
+ );
+
+ // Import each REDCap record into LORIS.
foreach ($records as $record) {
// if repeating instrument, contains the repeat index
$instrument_name = $record->getInstrumentName();
- // get the comment ID for that instrument
- $comment_id = $this->_getCommentId(
- $candidate,
- $visit_label,
- $instrument_name,
- );
-
- // instrument obj
- // TODO: for repeating instruments, the instrument with repeat_index
- // has to be created and accessible before using this.
- $instrument = \NDB_BVL_Instrument::factory(
- $this->_loris,
- $instrument_name,
- $comment_id,
- );
-
- // check if instrument has "Data_entry = 'In Progress'"
- if (!$instrument->determineDataEntryAllowed()) {
- error_log(
- "[redcap] [pscid:{$psc_id}][visit:$visit_label]"
- . "[instrument:$instrument_name] instrument already 'complete'."
- );
-
- // Mark the instrument as not updated.
+ if ($record_importer->import($record)) {
$instruments_not_updated[] = $instrument_name;
- continue;
+ } else {
+ $instruments_updated[] = $instrument_name;
}
-
- // dictionary diff between LORIS and REDCap match
- $this->_assertDictionaryMatches($instrument, $record);
-
- // update instrument
- $this->_updateInstrument($instrument, $record);
-
- // Mark the instrument as updated.
- $instruments_updated[] = $instrument_name;
}
// all not update = raise error
if (count($instruments_not_updated) === count($records)) {
throw new \LorisException(
- "[redcap] [pscid:{$psc_id}][visit:$visit_label]"
+ "[redcap] [pscid:{$psc_id}]"
. " instrument(s) not updatable."
);
}
@@ -203,538 +191,16 @@ class RedcapNotificationHandler
if (count($instruments_not_updated) > 0) {
$instruments_error_string = implode(',', $instruments_not_updated);
throw new \LorisException(
- "[redcap] [pscid:{$psc_id}][visit:$visit_label]"
+ "[redcap] [pscid:{$psc_id}]"
. " repeating instruments not updated: $instruments_error_string."
);
}
error_log(
- "[redcap] [pscid:{$psc_id}][visit:$visit_label] instrument updated."
+ "[redcap] [pscid:{$psc_id}] instrument updated."
);
}
- /**
- * Find the visit label associated with the REDCap notification based on the
- * REDCap module configuration.
- *
- * @return ?string The LORIS visit label associated with the REDCap
- * notification, or `NULL` if no corresponding visit label is
- * found.
- */
- private function _getVisitLabel(): ?string
- {
- // Get the list of all the REDCap events for this REDCap project.
- $redcap_events = $this->_redcap_client->getEvents();
-
- // Find the REDCap event that matches the REDCap notification event.
- $redcap_event = array_find(
- $redcap_events,
- fn($redcap_event) => (
- $redcap_event->unique_name === $this->_redcap_notif->event_name
- ),
- );
-
- // There should always be a REDCap event that matches the REDCap
- // notification event, but since this is an external API, check this
- // assumption nonetheless.
- if ($redcap_event === null) {
- throw new \LorisException(
- "[redcap] Error: No REDCap event found for notification event name"
- . " '{$this->_redcap_notif->event_name}'."
- );
- }
-
- // If there is no visit mappings in the REDCap module configuration, simply
- // use the REDCap event name as the LORIS vist name.
- if ($this->_redcap_config->visits === null) {
- return $redcap_event->name;
- }
-
- $event_name = $redcap_event->name;
- $arm_name = $this->_getRedcapEventArmName($redcap_event);
-
- // Look for the REDCap module configuration visit mappings that match the
- // REDCap notification event, using the event name and arm name.
- $visit_mappings = array_filter(
- $this->_redcap_config->visits,
- function ($visit_config) use ($event_name, $arm_name) {
- if ($visit_config->redcap_event_name !== null
- && $visit_config->redcap_event_name !== $event_name
- ) {
- return false;
- }
-
- if ($visit_config->redcap_arm_name !== null
- && $visit_config->redcap_arm_name !== $arm_name
- ) {
- return false;
- }
-
- return true;
- },
- );
-
- // If there is no visit mapping that match the REDCap notification event,
- // return `null` and ignore this notification.
- if (count($visit_mappings) === 0) {
- return null;
- }
-
- // If there are several visit mappings that match the REDCap notification
- // event, raise an error.
- if (count($visit_mappings) !== 1) {
- throw new \LorisException(
- "[redcap] Error: Multiple visits selectable for event name"
- . " '$event_name' and arm name '$arm_name'."
- );
- }
-
- // Return the LORIS visit label associated with the matching visit mapping.
- return reset($visit_mappings)->visit_label;
- }
-
- /**
- * Get the appropriate participant identifier of the REDCap notification based
- * on the REDCap module configuration.
- *
- * @return string The REDCap participant identifier.
- */
- private function _getRedcapParticipantId(): string
- {
- $record_id = $this->_redcap_notif->record_id;
-
- // If the REDCap module configuration is to use the REDCap record ID, simply
- // return the REDCap notification record ID.
- $condition = $this->_redcap_config->redcap_participant_id
- === RedcapConfigRedcapId::RecordId;
-
- if ($condition) {
- return $record_id;
- }
-
- // Get the list of all REDCap survey participants for the notification
- // instrument and event.
- $participants = $this->_redcap_client->getSurveyParticipants(
- $this->_redcap_notif->instrument_name,
- $this->_redcap_notif->event_name,
- );
-
- // Find the survey participant matching the notification record.
- $participant = array_find(
- $participants,
- fn($participant) => $participant->record === $record_id
- );
-
- // If no survey participant is found for that record, raise an error. This
- // can happen because even with surveys enabled in REDCap, there is no
- // requirement for all records to be linked to survey participants.
- if ($participant === null) {
- throw new \LorisException(
- "[redcap] Error: No survey participant found for record ID"
- . " '$record_id'."
- );
- }
-
- // If the REDCap survey participant does not have a custom identifier, raise
- // an error. This can happen because the survey participant identifier must
- // be set manually in REDCap and is optional.
- if ($participant->identifier === null) {
- throw new \LorisException(
- "[redcap] Error: Survey participant has no identifier for"
- . " record ID '$record_id'."
- );
- }
-
- // Return the identifier of the survey participant.
- return $participant->identifier;
- }
-
- /**
- * Get the LORIS candidate that matches a REDCap participant identifier based
- * on the REDCap module configuration.
- *
- * @param string $redcap_participant_id The REDCap participant identifier.
- *
- * @return \Candidate The LORIS candidate.
- */
- private function _getCandidateWithRedcapParticipantId(
- string $redcap_participant_id,
- ): \Candidate {
- // Get the candidate using the REDCap participant identifier as a CandID or
- // PSCID depending on the REDCap module configuration.
- $candidate = match ($this->_redcap_config->candidate_id) {
- RedcapConfigLorisId::CandId =>
- $this->_queries->tryGetCandidateWithCandId($redcap_participant_id),
- RedcapConfigLorisId::PscId =>
- $this->_queries->tryGetCandidateWithPscId($redcap_participant_id),
- };
-
- // If no candidate matches the REDCap participant identifier, raise an
- // error.
- if ($candidate === null) {
- throw new \LorisException(
- "[redcap] Error: No LORIS candidate found for REDCap participant"
- . " identifier '$redcap_participant_id'."
- );
- }
-
- // Return the candidate.
- return $candidate;
- }
-
- /**
- * Get the REDCap arm name of a REDCap event.
- *
- * @param RedcapEvent $redcap_event A REDCap event.
- *
- * @return string The REDCap event arm name.
- */
- private function _getRedcapEventArmName(RedcapEvent $redcap_event): string
- {
- // Get the list of all the REDCap arms for this REDCap project.
- $redcap_arms = $this->_redcap_client->getArms();
-
- // Find the REDCap arm that matches the REDCap event.
- $redcap_arm = array_find(
- $redcap_arms,
- fn($redcap_arm) => $redcap_arm->number === $redcap_event->arm_number,
- );
-
- // There should always be a REDCap arm that matches the REDCap event arm,
- // but since this is an external API, check this
- // assumption nonetheless.
- if ($redcap_arm === null) {
- throw new \LorisException(
- "[redcap] Error: No REDCap arm found for event arm number"
- . " '{$redcap_event->arm_number}'."
- );
- }
-
- // Return the name of the REDCap arm.
- return $redcap_arm->name;
- }
-
- /**
- * Check that REDCap and LORIS dictionaries matchfor this instrument.
- *
- * @param \NDB_BVL_Instrument $instrument a LORIS instrument object
- * @param IRedcapRecord $record a REDCap instrument record
- *
- * @throws \LorisException if a REDCap field not in LORIS instrument
- *
- * @return void
- */
- private function _assertDictionaryMatches(
- \NDB_BVL_Instrument $instrument,
- IRedcapRecord $record
- ): void {
- // var
- $instrument_name = $instrument->testName;
- $redcap_form_name = $record->getFormName();
- $dict_names = [];
-
- // -- LORIS INSTRUMENTS
- // Remove instrument name from LORIS field names (trim the first part)
- // E.g. from 'form_name_and_field_name' to 'and_field_name'
- foreach ($instrument->getDataDictionary() as $field) {
- $dict_names[] = preg_replace(
- "/^{$instrument_name}_/",
- "",
- $field->getName()
- );
- }
-
- // Remove LORIS internal fields
- $dict_names = array_filter(
- $dict_names,
- function ($name) {
- return !in_array(
- $name,
- self::LORIS_DD_EXCLUDE_FIELDS
- );
- }
- );
-
- // -- REDCap INSTRUMENTS
- // Remove REDCap specific fields
- $record_names = array_filter(
- $record->getPropertyNames(),
- function ($name) use ($redcap_form_name) {
- return !in_array(
- $name,
- [
- "redcap_data_access_group",
- "redcap_event_name",
- "redcap_survey_identifier",
- "{$redcap_form_name}_timestamp",
- "{$redcap_form_name}_complete",
- ]
- );
- }
- );
-
- // Remove instrument name from REDCap variable names if the variables are
- // prefixed by the instrument name.
- if ($this->_redcap_config->prefix_instrument_variable) {
- $record_names = array_values(
- array_map(
- function ($name) use ($redcap_form_name) {
- $retval = str_replace("{$redcap_form_name}", '', $name);
- $retval = ltrim($retval, '_');
- return $retval;
- },
- $record_names
- )
- );
- }
-
- // if REDCap checkbox (multiselect) options, extract the base name
- // REDCap checkbox is noted by a triple underscore and a the selected
- // option key i.e. "___{optionKey}"
- $record_names = array_unique(
- array_reduce(
- $record_names,
- function ($names, $name) {
- preg_match('/(.*)[_][_][_](.*)/', $name, $matches);
- if (!empty($matches[1])) {
- $name = $matches[1];
- }
- $names[] = $name;
- return $names;
- },
- []
- )
- );
-
- // -- DIFF LORIS-REDCap FIELD NAMES
- // Make sure all REDCap fields exists in LORIS.
- // Note: Score fields should be removed to have an exact match.
- $missing_fields = array_diff($record_names, $dict_names);
- if (!empty($missing_fields)) {
- $mf = implode(', ', $missing_fields);
- $msg = "[redcap] missing fields in LORIS: $mf";
- throw new \LorisException($msg);
- }
- }
-
- /**
- * Turn REDCap enum values into LINST answers.
- * Format as "value{@}value{@}value..."
- *
- * @param array $assoc_values REDCap instrument values
- *
- * @return array
- */
- private static function _formatEnumFields(array $assoc_values): array
- {
- $keys = array_keys($assoc_values);
- $reduced = array_reduce(
- $keys,
- function ($carry, $item) use ($assoc_values) {
- $field_name = $item;
- $value = $assoc_values[$field_name];
-
- preg_match('/(.*)[_][_][_](.*)/', $item, $matches);
- if (!empty($matches[1])) {
- // It is an enum field
- $new_field_name = $matches[1];
- $prev_value = isset($carry[$new_field_name])
- ? [$carry[$new_field_name]]
- : [];
- $new_value = $prev_value;
-
- if ($value == '1') {
- // The value is selected
- $value = [$matches[2]];
- $new_value = [
- implode(
- '{@}',
- array_merge($prev_value, $value)
- )
- ];
- }
-
- $value = array_shift($new_value);
- $field_name = $new_field_name;
- }
-
- if (!isset($carry[$field_name])) {
- $carry[$field_name] = null;
- }
-
- $carry[$field_name] = $value;
-
- return $carry;
-
- },
- []
- );
-
- return $reduced;
- }
-
- /**
- * Update a LORIS instrument data with a REDCap record data.
- *
- * @param \NDB_BVL_Instrument $instrument a LORIS instrument instance
- * @param IRedcapRecord $record a REDCap record
- *
- * @return void
- */
- private function _updateInstrument(
- \NDB_BVL_Instrument $instrument,
- IRedcapRecord $record
- ): void {
- // var
- $instrument_name = $record->getFormName();
- $instrument_values = $record->toArray();
- $comment_id = $instrument->getCommentID();
-
- // Add Examiner
- $instrument_values['Examiner'] = $this->_getRedcapExaminerId();
-
- // Remove instrument name from field name
- foreach ($instrument_values as $key => $value) {
- $new_key = str_replace($instrument_name, '', $key);
- $new_key = ltrim($new_key, '_');
- if ($key != $new_key) {
- $instrument_values[$new_key] = $value;
- unset($instrument_values[$key]);
- }
- }
-
- // -- Define/Add Date_taken
- // First, try based on 'dtt'
- // Then, if still null/undefined try based on 'timestamp_stop'
- // Then, if still null/undefined try based on 'timestamp_start'
- // Finally, if still null/undefined use current datetime
- if (isset($instrument_values['dtt'])) {
- $dt = \DateTime::createFromFormat(
- 'Y-m-d H:i:s',
- $instrument_values['dtt'],
- );
-
- if (!$dt) {
- error_log(
- "[redcap] Could not parse 'dtt': "
- . $instrument_values['dtt']
- );
- } else {
- $instrument_values['Date_taken'] = $dt->format('Y-m-d');
- }
- }
-
- // if null/empty, try getting that based on the timestamp
- if (isset($instrument_values['Date_taken'])
- || empty($instrument_values['Date_taken'])
- ) {
- if (isset($instrument_values['timestamp'])) {
- $dt = \DateTime::createFromFormat(
- 'Y-m-d H:i:s',
- $instrument_values['timestamp']
- );
- if (!$dt) {
- error_log(
- "[redcap] Could not parse 'timestamp': "
- . $instrument_values['timestamp']
- );
- } else {
- $instrument_values['Date_taken'] = $dt->format('Y-m-d');
- }
- }
- }
-
- // if null/empty, try getting that based on the timestamp_start
- if (isset($instrument_values['Date_taken'])
- || empty($instrument_values['Date_taken'])
- ) {
- if (isset($instrument_values['timestamp_start'])) {
- $dt = \DateTime::createFromFormat(
- 'Y-m-d H:i:s',
- $instrument_values['timestamp_start']
- );
- if (!$dt) {
- error_log(
- "[redcap] Could not parse 'timestamp_start': "
- . $instrument_values['timestamp_start']
- );
- } else {
- $instrument_values['Date_taken'] = $dt->format('Y-m-d');
- }
- }
- }
-
- // if still null/empty, get the current date
- if (isset($instrument_values['Date_taken'])
- || empty($instrument_values['Date_taken'])
- ) {
- $dtNow = new \DateTimeImmutable();
- $instrument_values['Date_taken'] = $dtNow->format('Y-m-d');
- }
-
- // add the timestamp_stop in the values based on the last timestamp
- if (isset($instrument_values['timestamp'])
- && !empty($instrument_values['timestamp'])
- ) {
- // rename var to uniformize with other LORIS instruments
- // Duration will be calculated when _saveValues is called.
- $instrument_values['timestamp_stop'] = $instrument_values['timestamp'];
- }
-
- // Aggregate enum values in a single field
- $instrument_values = $this->_formatEnumFields($instrument_values);
-
- // save values, score the instrument and mark mandatory elements done
- $instrument->_saveValues($instrument_values);
- $instrument->score();
- $instrument->updateRequiredElementsCompletedFlag();
-
- $this->_queries->markFlagAsComplete($comment_id);
- }
-
- /**
- * Get a commentID for a given participant/instrument/visit tuple.
- *
- * @param \Candidate $candidate A candidate.
- * @param string $visit_label A visit label.
- * @param string $instrument_name An instrument backend name.
- *
- * @throws \LorisException
- *
- * @return string
- */
- private function _getCommentId(
- \Candidate $candidate,
- string $visit_label,
- string $instrument_name,
- ): string {
- $psc_id = $candidate->getPSCID();
-
- $comment_id = $this->_queries->getCommentId(
- $candidate->getCandID(),
- $visit_label,
- $instrument_name,
- );
-
- if ($comment_id === null) {
- $msg = "[redcap] Error: no record in flag table for pscid:$psc_id,"
- ." visit:$visit_label, instrument:$instrument_name";
- throw new \LorisException($msg);
- }
-
- return $comment_id;
- }
-
- /**
- * Get the 'redcap' examiner ID.
- *
- * @return ?string the redcap examiner ID.
- */
- private function _getRedcapExaminerId(): ?string
- {
- return $this->_queries->getExaminerIdWithFullName('REDCap');
- }
-
/**
* Lock a notification.
*
@@ -750,7 +216,7 @@ class RedcapNotificationHandler
$this->_redcap_notif->received_datetime,
$this->_redcap_notif->project_id,
$this->_redcap_notif->record_id,
- $this->_redcap_notif->event_name,
+ $this->_redcap_notif->unique_event_name,
$this->_redcap_notif->instrument_name,
);
diff --git a/modules/redcap/php/queries.class.inc b/modules/redcap/php/redcapqueries.class.inc
similarity index 79%
rename from modules/redcap/php/queries.class.inc
rename to modules/redcap/php/redcapqueries.class.inc
index 29c4008a56f..39bcba224bd 100644
--- a/modules/redcap/php/queries.class.inc
+++ b/modules/redcap/php/redcapqueries.class.inc
@@ -1,9 +1,6 @@
_db = $loris->getDatabaseConnection();
}
+ /**
+ * Get the REDCap issue assignee from the configuration tables.
+ *
+ * @return \User The REDCap issue assignee user.
+ */
+ public function getRedcapIssueAssignee(): \User
+ {
+ $assignee_user_id = $this->_db->pselectOne(
+ "SELECT c.Value
+ FROM Config c
+ JOIN ConfigSettings cs ON (cs.ID = c.ConfigID)
+ WHERE cs.Name = 'redcap_issue_assignee'
+ ",
+ []
+ );
+
+ if ($assignee_user_id === null) {
+ throw new \LorisException(
+ "no REDCap issue assignee in configuration, missing"
+ . " 'redcap_issue_assignee'."
+ );
+ }
+
+ $assignee = $this->_db->pselectOne(
+ "SELECT DISTINCT u.userID
+ FROM users u
+ JOIN user_perm_rel upr ON (upr.userid = u.id)
+ JOIN permissions p ON (p.permid = upr.permid)
+ WHERE u.userID = :usid
+ AND u.Active = 'Y'
+ AND u.Pending_approval = 'N'
+ AND (
+ p.code = 'issue_tracker_developer'
+ OR p.code = 'superuser'
+ )
+ ",
+ ['usid' => $assignee_user_id]
+ );
+
+ if ($assignee === null) {
+ throw new \LorisException(
+ "REDCap issue assignee '$assignee_user_id' does not exist or does"
+ . " not have enough privileges."
+ );
+ }
+
+ return \User::factory($assignee_user_id);
+ }
+
/**
* Get a candidate from the database using a given PSCID.
*
* @param string $psc_id A potential PSCID.
*
- * @return ?\Candidate A LORIS candidate, or `NULL` if no candidate matches the
+ * @return ?\Candidate A LORIS candidate, or `null` if no candidate matches the
* given PSCID.
*/
public function tryGetCandidateWithPscId(string $psc_id): ?\Candidate
@@ -75,7 +121,7 @@ class Queries
*
* @param string $cand_id A potential CandID.
*
- * @return ?\Candidate A LORIS candidate, or `NULL` if no candidate matches the
+ * @return ?\Candidate A LORIS candidate, or `null` if no candidate matches the
* given CandID.
*/
public function tryGetCandidateWithCandId(string $cand_id): ?\Candidate
@@ -98,7 +144,7 @@ class Queries
*
* @param string $full_name A potential examiner full name, case-insensitive.
*
- * @return ?string The examiner ID, or `NULL` if no examiner matches the given
+ * @return ?string The examiner ID, or `null` if no examiner matches the given
* full name.
*/
public function getExaminerIdWithFullName(string $full_name): ?string
@@ -120,7 +166,7 @@ class Queries
* received.
* @param string $project_id The REDCap project ID.
* @param string $record_id The REDCap record ID.
- * @param string $event_name The REDCap event name.
+ * @param string $unique_event_name The REDCap unique event name.
* @param string $instrument_name The REDCap instrument name.
*
* @return array The list of unhandled notification IDs.
@@ -129,7 +175,7 @@ class Queries
\DatetimeImmutable $received_datetime,
string $project_id,
string $record_id,
- string $event_name,
+ string $unique_event_name,
string $instrument_name,
): array {
$query = "SELECT id
@@ -148,7 +194,7 @@ class Queries
'v_received_dt' => $received_datetime,
'v_project_id' => $project_id,
'v_record' => $record_id,
- 'v_redcap_event_name' => $event_name,
+ 'v_redcap_event_name' => $unique_event_name,
'v_instrument' => $instrument_name,
];
@@ -164,8 +210,8 @@ class Queries
* ID.
* @param string $record_id The REDCap notification record
* ID.
- * @param string $event_name The REDCap notification event
- * name.
+ * @param string $unique_event_name The REDCap notification unique
+ * event name.
* @param string $instrument_name The instrument name of the
* REDCap notification.
* @param \DateTimeImmutable $received_datetime The time at which the REDCap
@@ -178,7 +224,7 @@ class Queries
public function markRedcapNotifAsHandled(
string $project_id,
string $record_id,
- string $event_name,
+ string $unique_event_name,
string $instrument_name,
\DateTimeImmutable $received_datetime,
\DateTimeImmutable $handled_datetime,
@@ -201,7 +247,7 @@ class Queries
'v_received_dt' => $received_datetime,
'v_project_id' => $project_id,
'v_record' => $record_id,
- 'v_redcap_event_name' => $event_name,
+ 'v_redcap_event_name' => $unique_event_name,
'v_instrument' => $instrument_name,
'handled_dt' => $handled_datetime,
];
diff --git a/modules/redcap/php/redcaprecordimporter.class.inc b/modules/redcap/php/redcaprecordimporter.class.inc
new file mode 100644
index 00000000000..b42393fe07d
--- /dev/null
+++ b/modules/redcap/php/redcaprecordimporter.class.inc
@@ -0,0 +1,487 @@
+
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+
+namespace LORIS\redcap;
+
+use \LORIS\LorisInstance;
+use \LORIS\redcap\RedcapQueries;
+use \LORIS\redcap\client\RedcapHttpClient;
+use \LORIS\redcap\config\RedcapConfig;
+use \LORIS\redcap\client\models\records\IRedcapRecord;
+
+/**
+ * This represents a REDCap record importer.
+ *
+ * @category REDCap
+ * @package Main
+ * @author Regis Ongaro-Carcy
+ * @license http://www.gnu.org/licenses/gpl-3.0.txt GPLv3
+ * @link https://www.github.com/aces/Loris/
+ */
+class RedcapRecordImporter
+{
+ /**
+ * LORIS fields to be excluded when comparing with REDCap dictionary.
+ *
+ * @var array
+ */
+ const LORIS_DD_EXCLUDE_FIELDS = [
+ "Date_taken",
+ "Candidate_age",
+ "Window_Difference",
+ "Examiner",
+ ];
+
+ /**
+ * THe LORIS instance.
+ *
+ * @var LorisInstance
+ */
+ private LorisInstance $_loris;
+
+ /**
+ * The REDCap HTTP client.
+ *
+ * @var RedcapHttpClient
+ */
+ private RedcapHttpClient $_redcap_client;
+
+ /**
+ * The REDCap module configuration.
+ *
+ * @var RedcapConfig
+ */
+ private RedcapConfig $_config;
+
+ /**
+ * The REDCap mapper.
+ *
+ * @var RedcapMapper
+ */
+ private RedcapMapper $_mapper;
+
+ /**
+ * The REDCap module database queries.
+ *
+ * @var RedcapQueries
+ */
+ private RedcapQueries $_queries;
+
+ /**
+ * Constructor.
+ *
+ * @param LorisInstance $loris The LORIS instance.
+ * @param RedcapHttpClient $redcap_client The REDCap HTTP client.
+ * @param RedcapConfig $config The REDCap module configuration.
+ */
+ public function __construct(
+ LorisInstance $loris,
+ RedcapHttpClient $redcap_client,
+ RedcapConfig $config,
+ ) {
+ $queries = new RedcapQueries($loris);
+ $mapper = new RedcapMapper($loris, $redcap_client, $config);
+
+ $this->_loris = $loris;
+ $this->_redcap_client = $redcap_client;
+ $this->_config = $config;
+ $this->_mapper = $mapper;
+ $this->_queries = $queries;
+ }
+
+ /**
+ * Import a REDCap record into LORIS.
+ *
+ * @param IRedcapRecord $record The REDCap record.
+ *
+ * @return bool Whether or not the record has been succesfully imported.
+ */
+ public function import(IRedcapRecord $record): bool
+ {
+ // if repeating instrument, contains the repeat index
+ $instrument_name = $record->getInstrumentName();
+
+ $candidate_identifier = $this->_mapper->getCandidateIdentifier(
+ $record->record_id,
+ $instrument_name,
+ $record->unique_event_name,
+ );
+
+ $candidate = $this->_mapper->getCandidateWithIdentifier(
+ $candidate_identifier
+ );
+
+ $psc_id = $candidate->getPSCID();
+
+ $visit_label = $this->_mapper->getVisitLabel(
+ $record,
+ $record->unique_event_name,
+ $candidate,
+ );
+
+ // get the comment ID for that instrument
+ $comment_id = $this->_getCommentId(
+ $candidate,
+ $visit_label,
+ $instrument_name,
+ );
+
+ // instrument obj
+ // TODO: for repeating instruments, the instrument with repeat_index
+ // has to be created and accessible before using this.
+ $instrument = \NDB_BVL_Instrument::factory(
+ $this->_loris,
+ $instrument_name,
+ $comment_id,
+ );
+
+ // check if instrument has "Data_entry = 'In Progress'"
+ if (!$instrument->determineDataEntryAllowed()) {
+ error_log(
+ "[redcap] [pscid:{$psc_id}][visit:$visit_label]"
+ . "[instrument:$instrument_name] instrument already 'complete'."
+ );
+
+ return false;
+ }
+
+ // dictionary diff between LORIS and REDCap match
+ $this->_assertDictionaryMatches($instrument, $record);
+
+ // update instrument
+ $this->_updateInstrument($instrument, $record);
+
+ return true;
+ }
+
+ /**
+ * Check that the REDCap and LORIS dictionaries of an instrument match.
+ *
+ * @param \NDB_BVL_Instrument $instrument a LORIS instrument object
+ * @param IRedcapRecord $record a REDCap instrument record
+ *
+ * @throws \LorisException if a REDCap field not in LORIS instrument
+ *
+ * @return void
+ */
+ private function _assertDictionaryMatches(
+ \NDB_BVL_Instrument $instrument,
+ IRedcapRecord $record
+ ): void {
+ // var
+ $instrument_name = $instrument->testName;
+ $redcap_form_name = $record->getFormName();
+ $dict_names = [];
+
+ // -- LORIS INSTRUMENTS
+ // Remove instrument name from LORIS field names (trim the first part)
+ // E.g. from 'form_name_and_field_name' to 'and_field_name'
+ foreach ($instrument->getDataDictionary() as $field) {
+ $dict_names[] = preg_replace(
+ "/^{$instrument_name}_/",
+ "",
+ $field->getName()
+ );
+ }
+
+ // Remove LORIS internal fields
+ $dict_names = array_filter(
+ $dict_names,
+ function ($name) {
+ return !in_array(
+ $name,
+ self::LORIS_DD_EXCLUDE_FIELDS
+ );
+ }
+ );
+
+ // -- REDCap INSTRUMENTS
+ // Remove REDCap specific fields
+ $record_names = array_filter(
+ $record->getPropertyNames(),
+ function ($name) use ($redcap_form_name) {
+ return !in_array(
+ $name,
+ [
+ "record_id",
+ "redcap_data_access_group",
+ "redcap_event_name",
+ "redcap_survey_identifier",
+ "{$redcap_form_name}_timestamp",
+ "{$redcap_form_name}_complete",
+ ]
+ );
+ }
+ );
+
+ // Remove instrument name from REDCap variable names if the variables are
+ // prefixed by the instrument name.
+ if ($this->_config->prefix_instrument_variable) {
+ $record_names = array_values(
+ array_map(
+ function ($name) use ($redcap_form_name) {
+ $retval = str_replace("{$redcap_form_name}", '', $name);
+ $retval = ltrim($retval, '_');
+ return $retval;
+ },
+ $record_names
+ )
+ );
+ }
+
+ // if REDCap checkbox (multiselect) options, extract the base name
+ // REDCap checkbox is noted by a triple underscore and a the selected
+ // option key i.e. "___{optionKey}"
+ $record_names = array_unique(
+ array_reduce(
+ $record_names,
+ function ($names, $name) {
+ preg_match('/(.*)[_][_][_](.*)/', $name, $matches);
+ if (!empty($matches[1])) {
+ $name = $matches[1];
+ }
+ $names[] = $name;
+ return $names;
+ },
+ []
+ )
+ );
+
+ // -- DIFF LORIS-REDCap FIELD NAMES
+ // Make sure all REDCap fields exists in LORIS.
+ // Note: Score fields should be removed to have an exact match.
+ $missing_fields = array_diff($record_names, $dict_names);
+ if (!empty($missing_fields)) {
+ $mf = implode(', ', $missing_fields);
+ $msg = "[redcap] missing fields in LORIS: $mf";
+ throw new \LorisException($msg);
+ }
+ }
+
+ /**
+ * Turn REDCap enum values into LINST answers.
+ * Format as "value{@}value{@}value..."
+ *
+ * @param array $assoc_values REDCap instrument values
+ *
+ * @return array
+ */
+ private static function _formatEnumFields(array $assoc_values): array
+ {
+ $keys = array_keys($assoc_values);
+ $reduced = array_reduce(
+ $keys,
+ function ($carry, $item) use ($assoc_values) {
+ $field_name = $item;
+ $value = $assoc_values[$field_name];
+
+ preg_match('/(.*)[_][_][_](.*)/', $item, $matches);
+ if (!empty($matches[1])) {
+ // It is an enum field
+ $new_field_name = $matches[1];
+ $prev_value = isset($carry[$new_field_name])
+ ? [$carry[$new_field_name]]
+ : [];
+ $new_value = $prev_value;
+
+ if ($value == '1') {
+ // The value is selected
+ $value = [$matches[2]];
+ $new_value = [
+ implode(
+ '{@}',
+ array_merge($prev_value, $value)
+ )
+ ];
+ }
+
+ $value = array_shift($new_value);
+ $field_name = $new_field_name;
+ }
+
+ if (!isset($carry[$field_name])) {
+ $carry[$field_name] = null;
+ }
+
+ $carry[$field_name] = $value;
+
+ return $carry;
+
+ },
+ []
+ );
+
+ return $reduced;
+ }
+
+ /**
+ * Update a LORIS instrument data with a REDCap record data.
+ *
+ * @param \NDB_BVL_Instrument $instrument a LORIS instrument instance
+ * @param IRedcapRecord $record a REDCap record
+ *
+ * @return void
+ */
+ private function _updateInstrument(
+ \NDB_BVL_Instrument $instrument,
+ IRedcapRecord $record
+ ): void {
+ // var
+ $instrument_name = $record->getFormName();
+ $instrument_values = $record->toArray();
+ $comment_id = $instrument->getCommentID();
+
+ // Add Examiner
+ $instrument_values['Examiner'] = $this->_getRedcapExaminerId();
+
+ // Remove instrument name from field name
+ foreach ($instrument_values as $key => $value) {
+ $new_key = str_replace($instrument_name, '', $key);
+ $new_key = ltrim($new_key, '_');
+ if ($key != $new_key) {
+ $instrument_values[$new_key] = $value;
+ unset($instrument_values[$key]);
+ }
+ }
+
+ // -- Define/Add Date_taken
+ // First, try based on 'dtt'
+ // Then, if still null/undefined try based on 'timestamp_stop'
+ // Then, if still null/undefined try based on 'timestamp_start'
+ // Finally, if still null/undefined use current datetime
+ if (isset($instrument_values['dtt'])) {
+ $dt = \DateTime::createFromFormat(
+ 'Y-m-d H:i:s',
+ $instrument_values['dtt'],
+ );
+
+ if (!$dt) {
+ error_log(
+ "[redcap] Could not parse 'dtt': "
+ . $instrument_values['dtt']
+ );
+ } else {
+ $instrument_values['Date_taken'] = $dt->format('Y-m-d');
+ }
+ }
+
+ // if null/empty, try getting that based on the timestamp
+ if (isset($instrument_values['Date_taken'])
+ || empty($instrument_values['Date_taken'])
+ ) {
+ if (isset($instrument_values['timestamp'])) {
+ $dt = \DateTime::createFromFormat(
+ 'Y-m-d H:i:s',
+ $instrument_values['timestamp']
+ );
+ if (!$dt) {
+ error_log(
+ "[redcap] Could not parse 'timestamp': "
+ . $instrument_values['timestamp']
+ );
+ } else {
+ $instrument_values['Date_taken'] = $dt->format('Y-m-d');
+ }
+ }
+ }
+
+ // if null/empty, try getting that based on the timestamp_start
+ if (isset($instrument_values['Date_taken'])
+ || empty($instrument_values['Date_taken'])
+ ) {
+ if (isset($instrument_values['timestamp_start'])) {
+ $dt = \DateTime::createFromFormat(
+ 'Y-m-d H:i:s',
+ $instrument_values['timestamp_start']
+ );
+ if (!$dt) {
+ error_log(
+ "[redcap] Could not parse 'timestamp_start': "
+ . $instrument_values['timestamp_start']
+ );
+ } else {
+ $instrument_values['Date_taken'] = $dt->format('Y-m-d');
+ }
+ }
+ }
+
+ // if still null/empty, get the current date
+ if (isset($instrument_values['Date_taken'])
+ || empty($instrument_values['Date_taken'])
+ ) {
+ $dtNow = new \DateTimeImmutable();
+ $instrument_values['Date_taken'] = $dtNow->format('Y-m-d');
+ }
+
+ // add the timestamp_stop in the values based on the last timestamp
+ if (isset($instrument_values['timestamp'])
+ && !empty($instrument_values['timestamp'])
+ ) {
+ // rename var to uniformize with other LORIS instruments
+ // Duration will be calculated when _saveValues is called.
+ $instrument_values['timestamp_stop'] = $instrument_values['timestamp'];
+ }
+
+ // Aggregate enum values in a single field
+ $instrument_values = $this->_formatEnumFields($instrument_values);
+
+ // save values, score the instrument and mark mandatory elements done
+ $instrument->_saveValues($instrument_values);
+ $instrument->score();
+ $instrument->updateRequiredElementsCompletedFlag();
+
+ $this->_queries->markFlagAsComplete($comment_id);
+ }
+
+ /**
+ * Get a commentID for a given participant/instrument/visit tuple.
+ *
+ * @param \Candidate $candidate A candidate.
+ * @param string $visit_label A visit label.
+ * @param string $instrument_name An instrument backend name.
+ *
+ * @throws \LorisException
+ *
+ * @return string
+ */
+ private function _getCommentId(
+ \Candidate $candidate,
+ string $visit_label,
+ string $instrument_name,
+ ): string {
+ $psc_id = $candidate->getPSCID();
+
+ $comment_id = $this->_queries->getCommentId(
+ $candidate->getCandID(),
+ $visit_label,
+ $instrument_name,
+ );
+
+ if ($comment_id === null) {
+ $msg = "[redcap] Error: no record in flag table for pscid:$psc_id,"
+ ." visit:$visit_label, instrument:$instrument_name";
+ throw new \LorisException($msg);
+ }
+
+ return $comment_id;
+ }
+
+ /**
+ * Get the 'redcap' examiner ID.
+ *
+ * @return ?string the redcap examiner ID.
+ */
+ private function _getRedcapExaminerId(): ?string
+ {
+ return $this->_queries->getExaminerIdWithFullName('REDCap');
+ }
+}
diff --git a/tools/redcap2linst.php b/tools/redcap2linst.php
index 83d0ad02296..9d48cf58aba 100644
--- a/tools/redcap2linst.php
+++ b/tools/redcap2linst.php
@@ -381,7 +381,7 @@ function checkOptions(\LORIS\LorisInstance $loris, array &$options): array
$config = $config_parser->parse();
$redcap_client = new RedcapHttpClient(
- "{$config->redcap_instance_url}/api/",
+ $config->redcap_instance_url,
$config->redcap_api_token,
);