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
@@ -0,0 +1,65 @@
package io.redlink.more.studymanager.component.trigger.api;

import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.redlink.more.studymanager.core.component.Trigger;
import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
import io.redlink.more.studymanager.core.io.TriggerResult;
import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK;
import io.redlink.more.studymanager.core.sdk.schedule.CronSchedule;
import io.redlink.more.studymanager.core.io.ActionParameter;
import io.redlink.more.studymanager.core.io.Parameters;


public class ApiTrigger extends Trigger<TriggerProperties> {

private static final Logger LOGGER = LoggerFactory.getLogger(ApiTrigger.class);
public static final String PENDING_PARTICIPANTS_KEY = "pendingParticipants";

protected ApiTrigger(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException {
super(sdk, properties);
}

@Override
public void activate() {
// Poll every 5 seconds for pending trigger requests
String schedule = sdk.addSchedule(new CronSchedule("*/5 * * * * ?"));
sdk.setValue("scheduleId", schedule);
}

@Override
public void deactivate() {
sdk.getValue("scheduleId", String.class).ifPresent(sdk::removeSchedule);
}

@Override
@SuppressWarnings("unchecked")
public TriggerResult execute(Parameters parameters) {
// Read pending participants from storage
Set<Integer> pending = sdk.getValue(PENDING_PARTICIPANTS_KEY, HashSet.class)
.orElse(new HashSet<>());

if (pending.isEmpty()) {
return TriggerResult.NOOP;
}

LOGGER.info("Execute API trigger on study {} - triggering for {} participant(s): {}",
sdk.getStudyId(), pending.size(), pending);

// Build action parameters for all pending participants
Set<ActionParameter> actionParams = pending.stream()
.map(pid -> new ActionParameter(sdk.getStudyId(), pid))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You need first to filter pid and check if the parsed pid is

  • of an active participant
  • in an active study
  • and a participant that has the obervation

If not -> remove the participant from pending and update the DB

Alternatively but even better: You can check this already in the Gateway before writing and return a BAD_REQUEST if invalid participantIds are included. Than you can just ignore invalid participants at this point

.collect(Collectors.toSet());

// Clear pending list after processing
sdk.removeValue(PENDING_PARTICIPANTS_KEY);

return TriggerResult.withParams(actionParams);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.redlink.more.studymanager.component.trigger.api;

import java.util.List;

import io.redlink.more.studymanager.core.exception.ConfigurationValidationException;
import io.redlink.more.studymanager.core.factory.TriggerFactory;
import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.core.properties.model.Value;
import io.redlink.more.studymanager.core.sdk.MoreTriggerSDK;

public class ApiTriggerFactory extends TriggerFactory<ApiTrigger,TriggerProperties>{
private static List<Value> properties = List.of(

);
@Override
public String getId(){
return "api-trigger";
}

@Override
public String getTitle() {
return "Api trigger intervention";
}

@Override
public String getDescription() {
return "Intervention triggered by external api";
}

@Override
public List<Value> getProperties() {
return properties;
}

@Override
public ApiTrigger create(MoreTriggerSDK sdk, TriggerProperties properties) throws ConfigurationValidationException {
return new ApiTrigger(sdk, properties);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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, Österreichische Vereinigung zur
* Förderung der wissenschaftlichen Forschung).
* Licensed under the Elastic License 2.0.
*/
package io.redlink.more.studymanager.repository;

import io.redlink.more.studymanager.model.EndpointToken;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Component;

@Component
public class InterventionTokenRepository {
private static final String ADD_TOKEN =
"INSERT INTO intervention_api_tokens(study_id, intervention_id, token_id, token_label, token) " +
"VALUES (:study_id, :intervention_id, (SELECT COALESCE(MAX(token_id),0)+1 FROM intervention_api_tokens WHERE study_id = :study_id AND intervention_id = :intervention_id), :token_label, :token) " +
"RETURNING *";
private static final String LIST_TOKENS =
Copy link
Copy Markdown

@westei westei Feb 19, 2026

Choose a reason for hiding this comment

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

private static final String LIST_TOKENS = """
            SELECT token_id, token_label, created 
            FROM intervention_api_tokens 
            WHERE study_id = ? AND intervention_id = ?""";

"SELECT token_id, token_label, created " +
"FROM intervention_api_tokens " +
"WHERE study_id = ? AND intervention_id = ?";
private static final String GET_TOKEN =
"SELECT token_id, token_label, created " +
"FROM intervention_api_tokens " +
"WHERE study_id = ? AND intervention_id = ? AND token_id = ?";
private static final String GET_TOKEN_SECRET =
"SELECT token FROM intervention_api_tokens " +
"WHERE study_id = ? AND intervention_id = ? AND token_id = ?";
private static final String DELETE_TOKEN =
"DELETE FROM intervention_api_tokens " +
"WHERE study_id = ? AND intervention_id = ? AND token_id = ?";
private static final String DELETE_ALL_FOR_STUDY_ID =
"DELETE FROM intervention_api_tokens " +
"WHERE study_id = ?";

private final JdbcTemplate template;
private final NamedParameterJdbcTemplate namedTemplate;

public InterventionTokenRepository(JdbcTemplate template) {
this.template = template;
this.namedTemplate = new NamedParameterJdbcTemplate(template);
}

public Optional<EndpointToken> addToken(Long studyId, Integer interventionId, String tokenLabel, String encryptedSecret) {
try {
return Optional.ofNullable(namedTemplate.queryForObject(ADD_TOKEN,
new MapSqlParameterSource()
.addValue("token_label", tokenLabel)
.addValue("token", encryptedSecret)
.addValue("study_id", studyId)
.addValue("intervention_id", interventionId),
getHiddenTokenRowMapper()));
} catch (DuplicateKeyException e) {
return Optional.empty();
}
}

public List<EndpointToken> getAllTokens(Long studyId, Integer interventionId) {
return template.query(LIST_TOKENS, getHiddenTokenRowMapper(), studyId, interventionId);
}

public Optional<EndpointToken> getToken(Long studyId, Integer interventionId, Integer tokenId) {
try {
return Optional.ofNullable(template.queryForObject(GET_TOKEN, getHiddenTokenRowMapper(), studyId, interventionId, tokenId));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}

public Optional<String> getTokenSecret(Long studyId, Integer interventionId, Integer tokenId) {
try {
return Optional.ofNullable(template.queryForObject(GET_TOKEN_SECRET, String.class, studyId, interventionId, tokenId));
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}

public void deleteToken(Long studyId, Integer interventionId, Integer tokenId) {
template.update(DELETE_TOKEN, studyId, interventionId, tokenId);
}

public void clearForStudyId(long studyId) {
template.update(DELETE_ALL_FOR_STUDY_ID, studyId);
}

private static RowMapper<EndpointToken> getHiddenTokenRowMapper() {
return (rs, rowNum) -> new EndpointToken(
rs.getInt("token_id"),
rs.getString("token_label"),
RepositoryUtils.readInstant(rs, "created"),
null
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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, Österreichische Vereinigung zur
* Förderung der wissenschaftlichen Forschung).
* Licensed under the Elastic License 2.0.
*/
package io.redlink.more.studymanager.service;

import io.redlink.more.studymanager.model.EndpointToken;
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.repository.InterventionTokenRepository;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class InterventionTokenService {

private final StudyStateService studyStateService;
private final InterventionTokenRepository repository;
private final PasswordEncoder passwordEncoder;

public InterventionTokenService(StudyStateService studyStateService,
InterventionTokenRepository repository,
PasswordEncoder passwordEncoder) {
this.studyStateService = studyStateService;
this.repository = repository;
this.passwordEncoder = passwordEncoder;
}

public Optional<EndpointToken> addToken(Long studyId, Integer interventionId, String tokenLabel) {
studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED);
String secret = UUID.randomUUID().toString();

Optional<EndpointToken> newToken = repository.addToken(studyId, interventionId,
tokenLabel,
passwordEncoder.encode(secret)
);

return newToken.map(token ->
token.withToken(
String.format("%s.%s",
Base64.getEncoder().encodeToString(
String.format("%s-%s-%s", studyId, interventionId, token.tokenId()).getBytes(StandardCharsets.UTF_8)),
Base64.getEncoder().encodeToString(
secret.getBytes(StandardCharsets.UTF_8))
)
)
);
}

public List<EndpointToken> getTokens(Long studyId, Integer interventionId) {
return repository.getAllTokens(studyId, interventionId);
}

public Optional<EndpointToken> getToken(Long studyId, Integer interventionId, Integer tokenId) {
return repository.getToken(studyId, interventionId, tokenId);
}

public void deleteToken(Long studyId, Integer interventionId, Integer tokenId) {
studyStateService.assertStudyNotInState(studyId, Study.Status.CLOSED);
repository.deleteToken(studyId, interventionId, tokenId);
}

public void alignWithStudyState(Study study) {
if (study.getStudyState() == Study.Status.CLOSED) {
repository.clearForStudyId(study.getStudyId());
}
}

/**
* Validates an API token and returns the resolved study and intervention IDs.
*
* @param moreApiToken the token in format Base64(studyId-interventionId-tokenId).Base64(secret)
* @return resolved token info with studyId and interventionId
* @throws AccessDeniedException if the token is invalid
*/
public ResolvedToken validateToken(String moreApiToken) {
try {
String[] split = moreApiToken.split("\\.");
if (split.length != 2) {
throw new AccessDeniedException("Invalid token format");
}

String[] primaryKey = new String(
Base64.getDecoder().decode(split[0]), StandardCharsets.UTF_8
).split("-");
if (primaryKey.length != 3) {
throw new AccessDeniedException("Invalid token format");
}

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]), StandardCharsets.UTF_8
);

Optional<String> storedHash = repository.getTokenSecret(studyId, interventionId, tokenId);
if (storedHash.isEmpty() || !passwordEncoder.matches(secret, storedHash.get())) {
throw new AccessDeniedException("Invalid token");
}

return new ResolvedToken(studyId, interventionId);
} catch (AccessDeniedException e) {
throw e;
} catch (Exception e) {
throw new AccessDeniedException("Invalid token");
}
}

public record ResolvedToken(Long studyId, Integer interventionId) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public class StudyService {
private final ParticipantService participantService;
private final StudyStateService studyStateService;
private final IntegrationService integrationService;
private final InterventionTokenService interventionTokenService;
private final ElasticService elasticService;

private final PushNotificationService pushNotificationService;
Expand All @@ -66,7 +67,8 @@ public class StudyService {

public StudyService(StudyRepository studyRepository, StudyAclRepository aclRepository, UserRepository userRepo,
StudyStateService studyStateService, InterventionService interventionService, ObservationService observationService,
ParticipantService participantService, IntegrationService integrationService, ElasticService elasticService, PushNotificationService pushNotificationService, StudyGroupRepository studyGroupRepository) {
ParticipantService participantService, IntegrationService integrationService, InterventionTokenService interventionTokenService,
ElasticService elasticService, PushNotificationService pushNotificationService, StudyGroupRepository studyGroupRepository) {
this.studyRepository = studyRepository;
this.aclRepository = aclRepository;
this.userRepo = userRepo;
Expand All @@ -75,6 +77,7 @@ public StudyService(StudyRepository studyRepository, StudyAclRepository aclRepos
this.observationService = observationService;
this.participantService = participantService;
this.integrationService = integrationService;
this.interventionTokenService = interventionTokenService;
this.elasticService = elasticService;
this.pushNotificationService = pushNotificationService;
this.studyGroupRepository = studyGroupRepository;
Expand Down Expand Up @@ -169,6 +172,7 @@ private void alignWithStudyState(Study s) {
interventionService.alignInterventionsWithStudyState(s);
observationService.alignObservationsWithStudyState(s);
integrationService.alignIntegrationsWithStudyState(s);
interventionTokenService.alignWithStudyState(s);
}

public Map<MoreUser, Set<StudyRole>> getACL(Long studyId) {
Expand Down
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We have also DB changes incoming. So this will for sure create problems with the DB migration. As we do have already instances with data that are based on those migrations it would be great if this one could be done afterwards. I will provide a fitting name for the migration file

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Then when your db changes are pushed and merged revisit this so its not causing any issues.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
CREATE TABLE intervention_api_tokens (
study_id BIGINT NOT NULL,
intervention_id INT NOT NULL,
token_id SERIAL NOT NULL,
token_label VARCHAR NOT NULL,
token VARCHAR UNIQUE NOT NULL,
created TIMESTAMP NOT NULL DEFAULT now(),
PRIMARY KEY (study_id, intervention_id, token_id),
FOREIGN KEY (study_id, intervention_id) REFERENCES interventions(study_id, intervention_id) ON DELETE CASCADE
);
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class StudyServiceTest {
@Mock
StudyStateService studyStateService;

@Mock
InterventionTokenService interventionTokenService;

@InjectMocks
StudyService studyService;

Expand Down
Loading
Loading