Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public SecurityFilterChain filterChain(HttpSecurity http,
//External Data Gateway
req.requestMatchers("/api/v1/external/bulk")
.permitAll();
req.requestMatchers("/api/v1/trigger/external")
.permitAll();
req.requestMatchers("/api/v1/external/participants")
.permitAll();
req.requestMatchers("/api/v1/calendar/studies/*/calendar.ics")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright LBI-DHP and/or licensed to LBI-DHP under one or more
* contributor license agreements (LBI-DHP: Ludwig Boltzmann Institute
* for Digital Health and Prevention -- A research institute of the
* Ludwig Boltzmann Gesellschaft, Oesterreichische Vereinigung zur
* Foerderung der wissenschaftlichen Forschung).
* Licensed under the Elastic License 2.0.
*/
package io.redlink.more.data.controller;

import io.redlink.more.data.model.ResolvedInterventionToken;
import io.redlink.more.data.repository.StudyRepository;
import io.redlink.more.data.service.ExternalService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
public class TriggerProxyController {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the moreApiToken similar as in the ExternalDataApiV1Controller


private static final Logger LOG = LoggerFactory.getLogger(TriggerProxyController.class);
static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants";

private final StudyRepository studyRepository;
private final ExternalService externalService;

public TriggerProxyController(StudyRepository studyRepository, ExternalService externalService) {
this.studyRepository = studyRepository;
this.externalService = externalService;
}

@PostMapping(value = "/api/v1/trigger/external", produces = MediaType.APPLICATION_JSON_VALUE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add it to the OpenAPI Spec

public ResponseEntity<?> triggerExternal(
@RequestHeader("More-Api-Token") String moreApiToken,
@RequestBody TriggerRequest request
) {
LOG.info("Validating intervention token and writing pending participants to DB");

try {
ResolvedInterventionToken resolved = externalService.validateInterventionToken(moreApiToken);

if (!resolved.studyActive()) {
LOG.warn("Trigger rejected: study {} is not active", resolved.studyId());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Study not active"));
}

studyRepository.addPendingTriggerParticipants(
resolved.studyId(),
resolved.interventionId(),
PENDING_PARTICIPANTS_KEY,
request.participantIds()
);

return ResponseEntity.accepted().build();
} catch (AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} catch (Exception e) {
LOG.error("Error processing external trigger request", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}

public record TriggerRequest(List<Integer> participantIds) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.redlink.more.data.model;


public record ResolvedInterventionToken(
Long studyId,
Integer interventionId,
boolean studyActive
) {}
50 changes: 50 additions & 0 deletions src/main/java/io/redlink/more/data/repository/StudyRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.SerializationUtils;

import java.io.Serializable;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.function.Supplier;

import static io.redlink.more.data.repository.DbUtils.toInstant;
Expand Down Expand Up @@ -119,6 +123,21 @@ INNER JOIN studies s ON (t.study_id = s.study_id)
WHERE s.study_id = ? AND o.observation_id = ? AND t.token_id = ?
""";

private static final String GET_INTERVENTION_TOKEN_SECRET = """
SELECT t.study_id, t.intervention_id, t.token,
s.status IN ('active', 'preview') AS study_active
FROM intervention_api_tokens t
INNER JOIN interventions i ON (t.study_id = i.study_id AND t.intervention_id = i.intervention_id)
INNER JOIN studies s ON (t.study_id = s.study_id)
WHERE t.study_id = ? AND t.intervention_id = ? AND t.token_id = ?
""";

private static final String GET_PENDING_TRIGGER_PARTICIPANTS =
"SELECT value FROM nvpairs_triggers WHERE study_id = ? AND intervention_id = ? AND name = ? LIMIT 1 FOR UPDATE";
private static final String UPSERT_PENDING_TRIGGER_PARTICIPANTS =
"INSERT INTO nvpairs_triggers(study_id, intervention_id, name, value) VALUES (?,?,?,?) " +
"ON CONFLICT(study_id, intervention_id, name) DO UPDATE SET value = EXCLUDED.value";

private static final String GET_OBSERVATION_SCHEDULE = "SELECT schedule FROM observations WHERE study_id = ? AND observation_id = ?";

private static final String GET_PARTICIPANT_INFO_AND_START_DURATION_END_FOR_STUDY_AND_PARTICIPANT =
Expand Down Expand Up @@ -165,6 +184,37 @@ public Optional<ApiRoutingInfo> getApiRoutingInfo(Long studyId, Integer observat
}
}

public record InterventionTokenInfo(Long studyId, Integer interventionId, boolean studyActive, String secret) {}

public Optional<InterventionTokenInfo> getInterventionTokenInfo(Long studyId, Integer interventionId, Integer tokenId) {
try (var stream = jdbcTemplate.queryForStream(
GET_INTERVENTION_TOKEN_SECRET,
(rs, rowNum) -> new InterventionTokenInfo(
rs.getLong("study_id"),
rs.getInt("intervention_id"),
rs.getBoolean("study_active"),
rs.getString("token")
),
studyId, interventionId, tokenId
)) {
return stream.findFirst();
}
}

@Transactional
@SuppressWarnings("unchecked")
public void addPendingTriggerParticipants(Long studyId, Integer interventionId, String key, List<Integer> participantIds) {
Set<Integer> pending;
try {
byte[] raw = jdbcTemplate.queryForObject(GET_PENDING_TRIGGER_PARTICIPANTS, byte[].class, studyId, interventionId, key);
pending = raw != null ? (HashSet<Integer>) SerializationUtils.deserialize(raw) : new HashSet<>();
} catch (EmptyResultDataAccessException e) {
pending = new HashSet<>();
}
pending.addAll(participantIds);
jdbcTemplate.update(UPSERT_PENDING_TRIGGER_PARTICIPANTS, studyId, interventionId, key, SerializationUtils.serialize((Serializable) pending));
}

public Optional<ScheduleEvent> getObservationSchedule(Long studyId, Integer observationId) {
try {
return Optional.ofNullable(jdbcTemplate.queryForObject(
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/io/redlink/more/data/service/ExternalService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import io.redlink.more.data.model.ApiRoutingInfo;
import io.redlink.more.data.model.Participant;
import io.redlink.more.data.model.RoutingInfo;
import io.redlink.more.data.model.ResolvedInterventionToken;
import io.redlink.more.data.model.scheduler.Event;
import io.redlink.more.data.model.scheduler.Interval;
import io.redlink.more.data.model.scheduler.RelativeEvent;
Expand Down Expand Up @@ -114,6 +115,33 @@ public void assertTimestampsInBulk(Long studyId, Integer observationId, Integer
}
}


public ResolvedInterventionToken validateInterventionToken(String moreApiToken) {
try {
String[] split = moreApiToken.split("\\.");
String[] primaryKey = new String(Base64.getDecoder().decode(split[0])).split("-");

Long studyId = Long.valueOf(primaryKey[0]);
Integer interventionId = Integer.valueOf(primaryKey[1]);
Integer tokenId = Integer.valueOf(primaryKey[2]);
String secret = new String(Base64.getDecoder().decode(split[1]));

final Optional<StudyRepository.InterventionTokenInfo> tokenInfo =
repository.getInterventionTokenInfo(studyId, interventionId, tokenId)
.stream().filter(info ->
passwordEncoder.matches(secret, info.secret()))
.findFirst();
if (tokenInfo.isEmpty()) {
throw new AccessDeniedException("Invalid token");
}
return new ResolvedInterventionToken(tokenInfo.get().studyId(), tokenInfo.get().interventionId(), tokenInfo.get().studyActive());
} catch (AccessDeniedException e) {
throw e;
} catch (Exception e) {
throw new AccessDeniedException("Invalid token");
}
}

public List<Participant> listParticipants(Long studyId, OptionalInt studyGroupId) {
return repository.listParticipants(studyId, studyGroupId);
}
Expand Down
95 changes: 95 additions & 0 deletions src/main/resources/openapi/ExternalAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,66 @@ paths:
schema:
type: string
example: "An unexpected error occurred."

/trigger/external:
post:
operationId: triggerExternal
description: Trigger observation for specific participiants
tags:
- External trigger
parameters:
- $ref: '#/components/parameters/ExternalApiToken'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TriggerRequest'
responses:
'202':
description: Trigger sent .
content:
application/json:
schema:
type: string
example: "Trigger sent to the backend."
'403':
description: Token is invalid.
content:
application/json:
schema:
type: string
example: "Invalid token!"
'409':
description: Conflict (e.g., study or participant is not active).
content:
application/json:
schema:
type: string
example: "Study or participant is not active"
'422':
description: Unprocessable Entity (e.g., malformed participant ID ).
content:
application/json:
examples:
malformedId:
summary: Malformed participant ID
value: "Malformed participant id!"

'404':
description: Not Found (e.g., interval for observation not found).
content:
application/json:
schema:
type: string
example: "TimeFrame not found."
'400':
description: Bad Request (Any unhandled exception).
content:
application/json:
schema:
type: string
example: "An unexpected error occurred."

/external/participants:
get:
Expand Down Expand Up @@ -162,6 +222,41 @@ components:
- studyGroup
- start

TriggerRequest:
type: object
required:
- participantIds
properties:
participantIds:
type: array
items:
type: integer
description: List of participant IDs to trigger
example: [1, 2, 3]


ExternalTriggerRequest:
type: object
required:
- studyId
- interventionId
- participantIds
properties:
studyId:
type: integer
format: int64
description: The study ID extracted from the token
interventionId:
type: integer
format: int32
description: The intervention ID extracted from the token
participantIds:
type: array
items:
type: integer
description: List of participant IDs to trigger
example: [1, 2, 3]

ParticipantStatus:
type: string
enum:
Expand Down
Loading