From 007110838db5ceb9a56a6d7de2c37cdc79fe2a8f Mon Sep 17 00:00:00 2001 From: Devon Date: Fri, 11 Oct 2024 18:31:48 -0400 Subject: [PATCH] [JN-1419] tracking login on PortalParticipantUser (#1137) --- .../service/CurrentUserService.java | 5 + .../participant/PortalParticipantUserDao.java | 3 +- .../search/EnrolleeSearchExpressionDao.java | 171 ++++++++---------- .../participant/PortalParticipantUser.java | 2 + .../EnrolleeSearchExpressionResult.java | 6 +- .../PortalParticipantUserService.java | 3 +- .../EnrolleeSearchExpressionParser.java | 15 +- .../service/search/EnrolleeSearchService.java | 1 + .../sql/EnrolleeSearchQueryBuilder.java | 1 + .../service/search/terms/PortalUserTerm.java | 82 +++++++++ .../service/search/terms/SearchValue.java | 1 + .../core/service/search/terms/UserTerm.java | 16 +- .../2024_10_08_portal_last_login.yaml | 11 ++ .../db/changelog/db.changelog-master.yaml | 3 + .../EnrolleeSearchExpressionDaoTests.java | 60 +++++- .../search/EnrolleeSearchServiceTest.java | 4 +- .../EnrolleeSearchExpressionTest.java | 40 ++++ .../PortalParticipantUserPopulator.java | 5 +- ui-admin/src/api/api.tsx | 2 + .../participantList/ParticipantList.test.tsx | 2 +- .../participantList/ParticipantList.tsx | 2 +- .../participantList/ParticipantListTable.tsx | 2 +- ui-core/src/types/user.ts | 6 + 23 files changed, 324 insertions(+), 119 deletions(-) create mode 100644 core/src/main/java/bio/terra/pearl/core/service/search/terms/PortalUserTerm.java create mode 100644 core/src/main/resources/db/changelog/changesets/2024_10_08_portal_last_login.yaml diff --git a/api-participant/src/main/java/bio/terra/pearl/api/participant/service/CurrentUserService.java b/api-participant/src/main/java/bio/terra/pearl/api/participant/service/CurrentUserService.java index 413023c6ae..a573fa20a9 100644 --- a/api-participant/src/main/java/bio/terra/pearl/api/participant/service/CurrentUserService.java +++ b/api-participant/src/main/java/bio/terra/pearl/api/participant/service/CurrentUserService.java @@ -59,6 +59,11 @@ public UserLoginDto tokenLogin( UserLoginDto user = loadByToken(token, portalShortcode, environmentName); user.user.setLastLogin(Instant.now()); participantUserDao.update(user.user); + user.ppUsers.forEach( + ppUser -> { + ppUser.setLastLogin(Instant.now()); + portalParticipantUserService.update(ppUser); + }); return user; } diff --git a/core/src/main/java/bio/terra/pearl/core/dao/participant/PortalParticipantUserDao.java b/core/src/main/java/bio/terra/pearl/core/dao/participant/PortalParticipantUserDao.java index eeecf53490..b52e9bb1ca 100644 --- a/core/src/main/java/bio/terra/pearl/core/dao/participant/PortalParticipantUserDao.java +++ b/core/src/main/java/bio/terra/pearl/core/dao/participant/PortalParticipantUserDao.java @@ -1,6 +1,7 @@ package bio.terra.pearl.core.dao.participant; import bio.terra.pearl.core.dao.BaseJdbiDao; +import bio.terra.pearl.core.dao.BaseMutableJdbiDao; import bio.terra.pearl.core.model.EnvironmentName; import bio.terra.pearl.core.model.participant.PortalParticipantUser; import java.util.List; @@ -10,7 +11,7 @@ import org.springframework.stereotype.Component; @Component -public class PortalParticipantUserDao extends BaseJdbiDao { +public class PortalParticipantUserDao extends BaseMutableJdbiDao { private ProfileDao profileDao; public PortalParticipantUserDao(Jdbi jdbi, ProfileDao profileDao) { super(jdbi); diff --git a/core/src/main/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDao.java b/core/src/main/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDao.java index 687ae029e7..dbbf0f16da 100644 --- a/core/src/main/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDao.java +++ b/core/src/main/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDao.java @@ -2,12 +2,10 @@ import bio.terra.pearl.core.dao.participant.EnrolleeDao; import bio.terra.pearl.core.dao.participant.ProfileDao; +import bio.terra.pearl.core.model.BaseEntity; import bio.terra.pearl.core.model.address.MailingAddress; import bio.terra.pearl.core.model.kit.KitRequest; -import bio.terra.pearl.core.model.participant.Enrollee; -import bio.terra.pearl.core.model.participant.Family; -import bio.terra.pearl.core.model.participant.ParticipantUser; -import bio.terra.pearl.core.model.participant.Profile; +import bio.terra.pearl.core.model.participant.*; import bio.terra.pearl.core.model.search.EnrolleeSearchExpressionResult; import bio.terra.pearl.core.model.survey.Answer; import bio.terra.pearl.core.model.workflow.ParticipantTask; @@ -15,6 +13,8 @@ import bio.terra.pearl.core.service.search.EnrolleeSearchOptions; import bio.terra.pearl.core.service.search.expressions.DefaultSearchExpression; import bio.terra.pearl.core.service.search.sql.EnrolleeSearchQueryBuilder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.jdbi.v3.core.Handle; import org.jdbi.v3.core.Jdbi; import org.jdbi.v3.core.mapper.RowMapper; @@ -26,20 +26,34 @@ import org.jooq.SQLDialect; import org.jooq.impl.DSL; import org.springframework.stereotype.Component; +import org.springframework.util.StopWatch; import java.sql.ResultSet; import java.sql.SQLException; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.Consumer; +import java.util.function.BiConsumer; @Component +@Slf4j public class EnrolleeSearchExpressionDao { private final Jdbi jdbi; private final EnrolleeDao enrolleeDao; private final ProfileDao profileDao; + /** list of mappers for the various modules that can be included in a search result */ + protected static final List> moduleMappers = List.of( + new SearchModuleMapper<>("enrollee", Enrollee.class, EnrolleeSearchExpressionResult::setEnrollee), + new SearchModuleMapper<>("profile", Profile.class, EnrolleeSearchExpressionResult::setProfile), + new SearchModuleMapper<>("portalUser", PortalParticipantUser.class, EnrolleeSearchExpressionResult::setPortalParticipantUser), + new SearchModuleMapper<>("participant_user", ParticipantUser.class, EnrolleeSearchExpressionResult::setParticipantUser), + new SearchModuleMapper<>("mailing_address", MailingAddress.class, EnrolleeSearchExpressionResult::setMailingAddress), + new SearchModuleMapper<>("latest_kit", KitRequest.class, EnrolleeSearchExpressionResult::setLatestKit), + new SearchModuleCollectionMapper<>("answer", Answer.class, (result, answer) -> result.getAnswers().add(answer)), + new SearchModuleCollectionMapper<>("task", ParticipantTask.class, (result, task) -> result.getTasks().add(task))); + + public EnrolleeSearchExpressionDao(Jdbi jdbi, EnrolleeDao enrolleeDao, ProfileDao profileDao) { this.jdbi = jdbi; this.enrolleeDao = enrolleeDao; @@ -63,13 +77,13 @@ public List executeSearch(EnrolleeSearchExpressi public List executeSearch(EnrolleeSearchQueryBuilder search, EnrolleeSearchOptions opts) { return jdbi.withHandle(handle -> { org.jooq.Query jooqQuery = search.toQuery(DSL.using(SQLDialect.POSTGRES), opts); - Query query = jdbiFromJooq(jooqQuery, handle); - return query + var result = query .registerRowMapper(Family.class, BeanMapper.of(Family.class, "family")) .registerRowMapper(EnrolleeSearchExpressionResult.class, new EnrolleeSearchResultMapper()) .reduceRows(new EnrolleeSearchResultReducer()) .toList(); + return result; }); } @@ -89,96 +103,13 @@ private static Query jdbiFromJooq(org.jooq.Query jooqQuery, Handle handle) { public static class EnrolleeSearchResultMapper implements RowMapper { @Override public EnrolleeSearchExpressionResult map(ResultSet rs, StatementContext ctx) throws SQLException { - EnrolleeSearchExpressionResult enrolleeSearchExpressionResult = new EnrolleeSearchExpressionResult(); - - enrolleeSearchExpressionResult.setEnrollee( - BeanMapper.of(Enrollee.class, "enrollee") - .map(rs, ctx) - ); - - enrolleeSearchExpressionResult.setProfile( - BeanMapper.of(Profile.class, "profile") - .map(rs, ctx) - ); - - // anything that starts with "task" will be added to the tasks list - mapAllBeans( - rs, - ctx, - ParticipantTask.class, - "task", - enrolleeSearchExpressionResult.getTasks()::add - ); - - mapAllBeans( - rs, - ctx, - Answer.class, - "answer", - enrolleeSearchExpressionResult.getAnswers()::add - ); - - mapBean(rs, - ctx, - MailingAddress.class, - "mailing_address", - enrolleeSearchExpressionResult::setMailingAddress); - - mapBean(rs, - ctx, - KitRequest.class, - "latest_kit", - enrolleeSearchExpressionResult::setLatestKit); - - mapBean(rs, - ctx, - ParticipantUser.class, - "participant_user", - enrolleeSearchExpressionResult::setParticipantUser); - - return enrolleeSearchExpressionResult; - } - - private boolean isColumnPresent(ResultSet rs, String columnName) { - try { - rs.findColumn(columnName); - return true; - } catch (SQLException e) { - return false; + EnrolleeSearchExpressionResult result = new EnrolleeSearchExpressionResult(); + for (SearchModuleMapper processor : moduleMappers) { + processor.map(rs, ctx, result); } + return result; } - - private void mapBean( - ResultSet rs, - StatementContext ctx, - Class clazz, - String prefix, - Consumer callback) throws SQLException { - if (isColumnPresent(rs, prefix + "_id")) { - callback.accept(BeanMapper.of(clazz, prefix).map(rs, ctx)); - } - } - - private void mapAllBeans( - ResultSet rs, - StatementContext ctx, - Class clazz, - String prefix, - Consumer callback) throws SQLException { - // Loop through all the columns to see if any of the possible extra objects - // are present. - // (the column count starts from 1) - for (int i = 1; i < rs.getMetaData().getColumnCount(); i++) { - String columnName = rs.getMetaData().getColumnName(i); - if (columnName.startsWith(prefix) && columnName.endsWith("_created_at")) { - String modelName = columnName.substring( - 0, - columnName.length() - "_created_at".length()); - callback.accept(BeanMapper.of(clazz, modelName).map(rs, ctx)); - } - } - } } /** @@ -204,4 +135,56 @@ private boolean isColumnPresent(RowView rv, String columnName, Class c) { } } } + + /** for simple beans that need to be mapped to a property in the EnrolleeSearchExpressionResult, like Profile */ + @Getter + private static class SearchModuleMapper { + protected final String prefix; + protected final RowMapper mapper; + protected final BiConsumer consumer; + protected final Class clazz; + + public SearchModuleMapper(String prefix, Class clazz, BiConsumer consumer) { + this.prefix = prefix; + this.clazz = clazz; + this.mapper = BeanMapper.of(clazz, prefix); + this.consumer = consumer; + } + + public void map(ResultSet rs, StatementContext ctx, EnrolleeSearchExpressionResult result) throws SQLException { + if (isColumnPresent(rs, prefix + "_id")) { + consumer.accept(result, mapper.map(rs, ctx)); + } + } + + private boolean isColumnPresent(ResultSet rs, String columnName) { + try { + rs.findColumn(columnName); + return true; + } catch (SQLException e) { + return false; + } + } + } + + /** for modules that are mapped to a collection in the EnrolleeSearchExpressionResult, such as tasks */ + private static class SearchModuleCollectionMapper extends SearchModuleMapper { + public SearchModuleCollectionMapper(String prefix, Class clazz, BiConsumer consumer) { + super(prefix, clazz, consumer); + } + + @Override + public void map(ResultSet rs, StatementContext ctx, EnrolleeSearchExpressionResult result) throws SQLException { + for (int i = 1; i < rs.getMetaData().getColumnCount(); i++) { + String columnName = rs.getMetaData().getColumnName(i); + if (columnName.startsWith(prefix) && columnName.endsWith("_created_at")) { + String itemPrefix = columnName.substring( + 0, + columnName.length() - "_created_at".length()); + RowMapper itemMapper = BeanMapper.of(clazz, itemPrefix); + consumer.accept(result, itemMapper.map(rs, ctx)); + } + } + } + } } diff --git a/core/src/main/java/bio/terra/pearl/core/model/participant/PortalParticipantUser.java b/core/src/main/java/bio/terra/pearl/core/model/participant/PortalParticipantUser.java index ca963d6150..b4dbfd000b 100644 --- a/core/src/main/java/bio/terra/pearl/core/model/participant/PortalParticipantUser.java +++ b/core/src/main/java/bio/terra/pearl/core/model/participant/PortalParticipantUser.java @@ -7,6 +7,7 @@ import lombok.Setter; import lombok.experimental.SuperBuilder; +import java.time.Instant; import java.util.UUID; @Getter @@ -20,4 +21,5 @@ public class PortalParticipantUser extends BaseEntity { private UUID portalEnvironmentId; private Profile profile; private UUID profileId; + private Instant lastLogin; } diff --git a/core/src/main/java/bio/terra/pearl/core/model/search/EnrolleeSearchExpressionResult.java b/core/src/main/java/bio/terra/pearl/core/model/search/EnrolleeSearchExpressionResult.java index 8cad2c2955..16801ffb70 100644 --- a/core/src/main/java/bio/terra/pearl/core/model/search/EnrolleeSearchExpressionResult.java +++ b/core/src/main/java/bio/terra/pearl/core/model/search/EnrolleeSearchExpressionResult.java @@ -2,10 +2,7 @@ import bio.terra.pearl.core.model.address.MailingAddress; import bio.terra.pearl.core.model.kit.KitRequest; -import bio.terra.pearl.core.model.participant.Enrollee; -import bio.terra.pearl.core.model.participant.Family; -import bio.terra.pearl.core.model.participant.ParticipantUser; -import bio.terra.pearl.core.model.participant.Profile; +import bio.terra.pearl.core.model.participant.*; import bio.terra.pearl.core.model.survey.Answer; import bio.terra.pearl.core.model.workflow.ParticipantTask; import lombok.Getter; @@ -24,6 +21,7 @@ public class EnrolleeSearchExpressionResult { private Enrollee enrollee; private Profile profile; private ParticipantUser participantUser; + private PortalParticipantUser portalParticipantUser; private MailingAddress mailingAddress; private final List answers = new ArrayList<>(); private final List tasks = new ArrayList<>(); diff --git a/core/src/main/java/bio/terra/pearl/core/service/participant/PortalParticipantUserService.java b/core/src/main/java/bio/terra/pearl/core/service/participant/PortalParticipantUserService.java index 42f4fc0b89..8406ea403b 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/participant/PortalParticipantUserService.java +++ b/core/src/main/java/bio/terra/pearl/core/service/participant/PortalParticipantUserService.java @@ -8,6 +8,7 @@ import bio.terra.pearl.core.model.participant.PortalParticipantUser; import bio.terra.pearl.core.model.participant.Profile; import bio.terra.pearl.core.service.CascadeProperty; +import bio.terra.pearl.core.service.CrudService; import bio.terra.pearl.core.service.ImmutableEntityService; import bio.terra.pearl.core.service.workflow.ParticipantDataChangeService; import org.springframework.context.annotation.Lazy; @@ -20,7 +21,7 @@ import java.util.UUID; @Service -public class PortalParticipantUserService extends ImmutableEntityService { +public class PortalParticipantUserService extends CrudService { private final ProfileService profileService; private final PreregistrationResponseDao preregistrationResponseDao; private final ParticipantDataChangeService participantDataChangeService; diff --git a/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchExpressionParser.java b/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchExpressionParser.java index 05a76a319d..7bb783cc57 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchExpressionParser.java +++ b/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchExpressionParser.java @@ -40,8 +40,9 @@ public class EnrolleeSearchExpressionParser { private final KitRequestDao kitRequestDao; private final FamilyDao familyDao; private final ParticipantUserDao participantUserDao; + private final PortalParticipantUserDao portalParticipantUserDao; - public EnrolleeSearchExpressionParser(EnrolleeDao enrolleeDao, AnswerDao answerDao, ProfileDao profileDao, MailingAddressDao mailingAddressDao, ParticipantTaskDao participantTaskDao, KitRequestDao kitRequestDao, FamilyDao familyDao, ParticipantUserDao participantUserDao) { + public EnrolleeSearchExpressionParser(EnrolleeDao enrolleeDao, AnswerDao answerDao, ProfileDao profileDao, MailingAddressDao mailingAddressDao, ParticipantTaskDao participantTaskDao, KitRequestDao kitRequestDao, FamilyDao familyDao, ParticipantUserDao participantUserDao, PortalParticipantUserDao portalParticipantUserDao) { this.enrolleeDao = enrolleeDao; this.answerDao = answerDao; this.profileDao = profileDao; @@ -50,6 +51,7 @@ public EnrolleeSearchExpressionParser(EnrolleeDao enrolleeDao, AnswerDao answerD this.kitRequestDao = kitRequestDao; this.familyDao = familyDao; this.participantUserDao = participantUserDao; + this.portalParticipantUserDao = portalParticipantUserDao; } @@ -191,44 +193,39 @@ private SearchTerm parseVariableTerm(String variable) { switch (model) { case "profile": String profileField = parseField(trimmedVar); - return new ProfileTerm(profileDao, mailingAddressDao, profileField); case "answer": String[] answerFields = parseFields(trimmedVar); if (answerFields.length != 2) { throw new IllegalArgumentException("Invalid answer variable"); } - return new AnswerTerm(answerDao, answerFields[0], answerFields[1]); case "age": if (!trimmedVar.equals(model)) { throw new IllegalArgumentException("Invalid age variable"); } - return new AgeTerm(profileDao); case "enrollee": String enrolleeField = parseField(trimmedVar); - return new EnrolleeTerm(enrolleeField); case "task": String[] taskFields = parseFields(trimmedVar); if (taskFields.length != 2) { throw new IllegalArgumentException("Invalid answer variable"); } - return new TaskTerm(participantTaskDao, taskFields[0], taskFields[1]); case "latestKit": String latestKitField = parseField(trimmedVar); - return new LatestKitTerm(kitRequestDao, latestKitField); case "family": String familyField = parseField(trimmedVar); - return new FamilyTerm(familyDao, familyField); case "user": String userField = parseField(trimmedVar); - return new UserTerm(participantUserDao, userField); + case "portalUser": + String portalUserField = parseField(trimmedVar); + return new PortalUserTerm(portalParticipantUserDao, portalUserField); default: throw new IllegalArgumentException("Unknown model " + model); } diff --git a/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchService.java b/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchService.java index 76b6940f38..1547efa175 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchService.java +++ b/core/src/main/java/bio/terra/pearl/core/service/search/EnrolleeSearchService.java @@ -47,6 +47,7 @@ public Map getExpressionSearchFacetsForStudyE EnrolleeTerm.FIELDS.forEach((term, type) -> fields.put("enrollee." + term, type)); // latest kit fields LatestKitTerm.FIELDS.forEach((term, type) -> fields.put("latestKit." + term, type)); + PortalUserTerm.FIELDS.forEach((term, type) -> fields.put("portalUser." + term, type)); // age fields.put("age", SearchValueTypeDefinition.builder().type(NUMBER).build()); // answers diff --git a/core/src/main/java/bio/terra/pearl/core/service/search/sql/EnrolleeSearchQueryBuilder.java b/core/src/main/java/bio/terra/pearl/core/service/search/sql/EnrolleeSearchQueryBuilder.java index d7ebb882bf..93327b11c2 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/search/sql/EnrolleeSearchQueryBuilder.java +++ b/core/src/main/java/bio/terra/pearl/core/service/search/sql/EnrolleeSearchQueryBuilder.java @@ -2,6 +2,7 @@ import bio.terra.pearl.core.dao.BaseJdbiDao; import bio.terra.pearl.core.dao.participant.EnrolleeDao; +import bio.terra.pearl.core.dao.participant.PortalParticipantUserDao; import bio.terra.pearl.core.dao.participant.ProfileDao; import bio.terra.pearl.core.service.search.EnrolleeSearchOptions; import lombok.Getter; diff --git a/core/src/main/java/bio/terra/pearl/core/service/search/terms/PortalUserTerm.java b/core/src/main/java/bio/terra/pearl/core/service/search/terms/PortalUserTerm.java new file mode 100644 index 0000000000..5b9a5af44e --- /dev/null +++ b/core/src/main/java/bio/terra/pearl/core/service/search/terms/PortalUserTerm.java @@ -0,0 +1,82 @@ +package bio.terra.pearl.core.service.search.terms; + +import bio.terra.pearl.core.dao.participant.ParticipantUserDao; +import bio.terra.pearl.core.dao.participant.PortalParticipantUserDao; +import bio.terra.pearl.core.model.kit.KitRequest; +import bio.terra.pearl.core.model.participant.PortalParticipantUser; +import bio.terra.pearl.core.model.search.SearchValueTypeDefinition; +import bio.terra.pearl.core.service.search.EnrolleeSearchContext; +import bio.terra.pearl.core.service.search.sql.EnrolleeSearchQueryBuilder; +import com.google.api.gax.rpc.UnimplementedException; +import org.jooq.Condition; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static bio.terra.pearl.core.dao.BaseJdbiDao.toSnakeCase; +import static bio.terra.pearl.core.service.search.terms.SearchValue.SearchValueType.DATE; +import static bio.terra.pearl.core.service.search.terms.SearchValue.SearchValueType.INSTANT; + +/** a term for the PortalParticipantUser (named PortalUser because the expression term is "portalUser") */ +public class PortalUserTerm implements SearchTerm { + + private final String field; + private final PortalParticipantUserDao portalParticipantUserDao; + + public PortalUserTerm(PortalParticipantUserDao portalParticipantUserDao, String field) { + if (!FIELDS.containsKey(field)) { + throw new IllegalArgumentException("Invalid field: " + field); + } + this.portalParticipantUserDao = portalParticipantUserDao; + this.field = field; + } + + @Override + public SearchValue extract(EnrolleeSearchContext context) { + Optional ppUser = portalParticipantUserDao.findByProfileId(context.getEnrollee().getProfileId()); + if (ppUser.isEmpty()) { + return new SearchValue(); + } + return SearchValue.ofNestedProperty(ppUser.get(), field, FIELDS.get(field).getType()); + } + + @Override + public List requiredJoinClauses() { + return List.of( + new EnrolleeSearchQueryBuilder.JoinClause("portal_participant_user", "portalUser", "portalUser.profile_id = profile.id") + ); + } + + @Override + public List requiredSelectClauses() { + return List.of( + new EnrolleeSearchQueryBuilder.SelectClause("portalUser", portalParticipantUserDao) + ); + } + + @Override + public Optional requiredConditions() { + return Optional.empty(); + } + + @Override + public String termClause() { + return "portalUser." + toSnakeCase(field); + } + + @Override + public List boundObjects() { + return List.of(); + } + + @Override + public SearchValueTypeDefinition type() { + return FIELDS.get(field); + } + + public static final Map FIELDS = Map.ofEntries( + Map.entry("createdAt", SearchValueTypeDefinition.builder().type(INSTANT).build()), + Map.entry("lastLogin", SearchValueTypeDefinition.builder().type(INSTANT).build())); + +} diff --git a/core/src/main/java/bio/terra/pearl/core/service/search/terms/SearchValue.java b/core/src/main/java/bio/terra/pearl/core/service/search/terms/SearchValue.java index ca07dc5ec9..921b72adc8 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/search/terms/SearchValue.java +++ b/core/src/main/java/bio/terra/pearl/core/service/search/terms/SearchValue.java @@ -62,6 +62,7 @@ public static SearchValue of(Object objValue, SearchValueType type) { return switch (type) { case STRING -> new SearchValue(objValue.toString()); case DATE -> new SearchValue((LocalDate) objValue); + case INSTANT -> new SearchValue((Instant) objValue); case NUMBER -> new SearchValue((Double) objValue); case BOOLEAN -> new SearchValue((Boolean) objValue); default -> throw new IllegalArgumentException("Invalid field type: " + type); diff --git a/core/src/main/java/bio/terra/pearl/core/service/search/terms/UserTerm.java b/core/src/main/java/bio/terra/pearl/core/service/search/terms/UserTerm.java index 2822182491..01f1b9afa2 100644 --- a/core/src/main/java/bio/terra/pearl/core/service/search/terms/UserTerm.java +++ b/core/src/main/java/bio/terra/pearl/core/service/search/terms/UserTerm.java @@ -1,6 +1,8 @@ package bio.terra.pearl.core.service.search.terms; import bio.terra.pearl.core.dao.participant.ParticipantUserDao; +import bio.terra.pearl.core.model.participant.ParticipantUser; +import bio.terra.pearl.core.model.participant.PortalParticipantUser; import bio.terra.pearl.core.model.search.SearchValueTypeDefinition; import bio.terra.pearl.core.service.search.EnrolleeSearchContext; import bio.terra.pearl.core.service.search.sql.EnrolleeSearchQueryBuilder; @@ -11,10 +13,10 @@ import java.util.Optional; import static bio.terra.pearl.core.dao.BaseJdbiDao.toSnakeCase; -import static bio.terra.pearl.core.service.search.terms.SearchValue.SearchValueType.DATE; +import static bio.terra.pearl.core.service.search.terms.SearchValue.SearchValueType.*; /** - * Allows searching on basic user properties, e.g. "lastLogin" + * Allows searching on basic ParticipantUser properties, e.g. "lastLogin" */ public class UserTerm implements SearchTerm { @@ -32,7 +34,11 @@ public UserTerm(ParticipantUserDao participantUserDao, String field) { @Override public SearchValue extract(EnrolleeSearchContext context) { - return SearchValue.ofNestedProperty(context.getEnrollee(), field, FIELDS.get(field).getType()); + Optional user = participantUserDao.find(context.getEnrollee().getParticipantUserId()); + if (user.isEmpty()) { + return new SearchValue(); + } + return SearchValue.ofNestedProperty(user.get(), field, FIELDS.get(field).getType()); } @Override @@ -70,6 +76,8 @@ public SearchValueTypeDefinition type() { } public static final Map FIELDS = Map.ofEntries( - Map.entry("lastLogin", SearchValueTypeDefinition.builder().type(DATE).build())); + Map.entry("username", SearchValueTypeDefinition.builder().type(STRING).build()), + Map.entry("createdAt", SearchValueTypeDefinition.builder().type(INSTANT).build()), + Map.entry("lastLogin", SearchValueTypeDefinition.builder().type(INSTANT).build())); } diff --git a/core/src/main/resources/db/changelog/changesets/2024_10_08_portal_last_login.yaml b/core/src/main/resources/db/changelog/changesets/2024_10_08_portal_last_login.yaml new file mode 100644 index 0000000000..6a1f1899c1 --- /dev/null +++ b/core/src/main/resources/db/changelog/changesets/2024_10_08_portal_last_login.yaml @@ -0,0 +1,11 @@ +databaseChangeLog: # This script is DEPRECATED -- it has been replaced by RolePopulator. This has been removed from the changelog + - changeSet: + id: "portal_user_login" + author: dbush + changes: + - renameColumn: + tableName: portal_participant_user + oldColumnName: lastLogin + newColumnName: last_login + - sql: # since our portals are geographically distinct right now, we can assume that the last login time is the same as the last login time of the participant_user + sql: update portal_participant_user set last_login = p.last_login from participant_user p where portal_participant_user.participant_user_id = p.id; diff --git a/core/src/main/resources/db/changelog/db.changelog-master.yaml b/core/src/main/resources/db/changelog/db.changelog-master.yaml index 19199760df..d37a7004d6 100644 --- a/core/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/core/src/main/resources/db/changelog/db.changelog-master.yaml @@ -332,6 +332,9 @@ databaseChangeLog: - include: file: changesets/2024_09_30_referenced_questions.yaml relativeToChangelogFile: true + - include: + file: changesets/2024_10_08_portal_last_login.yaml + relativeToChangelogFile: true # README: it is a best practice to put each DDL statement in its own change set. DDL statements diff --git a/core/src/test/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDaoTests.java b/core/src/test/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDaoTests.java index 112b9bd837..ca0530bbce 100644 --- a/core/src/test/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDaoTests.java +++ b/core/src/test/java/bio/terra/pearl/core/dao/search/EnrolleeSearchExpressionDaoTests.java @@ -11,6 +11,7 @@ import bio.terra.pearl.core.model.kit.KitRequestStatus; import bio.terra.pearl.core.model.participant.Enrollee; import bio.terra.pearl.core.model.participant.Family; +import bio.terra.pearl.core.model.participant.PortalParticipantUser; import bio.terra.pearl.core.model.participant.Profile; import bio.terra.pearl.core.model.portal.PortalEnvironment; import bio.terra.pearl.core.model.search.EnrolleeSearchExpressionResult; @@ -19,6 +20,7 @@ import bio.terra.pearl.core.model.workflow.TaskStatus; import bio.terra.pearl.core.model.workflow.TaskType; import bio.terra.pearl.core.service.kit.pepper.PepperKitStatus; +import bio.terra.pearl.core.service.participant.PortalParticipantUserService; import bio.terra.pearl.core.service.search.EnrolleeSearchExpression; import bio.terra.pearl.core.service.search.EnrolleeSearchExpressionParser; import com.fasterxml.jackson.core.JsonProcessingException; @@ -29,11 +31,17 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; import java.time.LocalDate; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalField; import java.util.List; import java.util.Map; import java.util.UUID; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -65,6 +73,8 @@ public class EnrolleeSearchExpressionDaoTests extends BaseSpringBootTest { @Autowired FamilyFactory familyFactory; + @Autowired + PortalParticipantUserService portalParticipantUserService; @Test @@ -529,7 +539,28 @@ public void testLatestKit(TestInfo info) throws JsonProcessingException { enrollee.getStudyEnvironmentId()); assertEquals(0, resultsCreated.size()); + } + + @Test + @Transactional + public void testPortalUser(TestInfo info) { + StudyEnvironmentBundle studyEnvBundle = studyEnvironmentFactory.buildBundle(getTestName(info), EnvironmentName.sandbox); + EnrolleeBundle bundle = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv()); + Instant loginTime = Instant.now(); + bundle.portalParticipantUser().setLastLogin(loginTime); + portalParticipantUserService.update(bundle.portalParticipantUser()); + EnrolleeBundle bundle2 = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv()); + loginTime = Instant.now().minusSeconds(3600); + bundle2.portalParticipantUser().setLastLogin(loginTime); + portalParticipantUserService.update(bundle2.portalParticipantUser()); + + EnrolleeSearchExpression nameExp = enrolleeSearchExpressionParser.parseRule("{portalUser.lastLogin} < {user.createdAt}"); + + List resultsName = enrolleeSearchExpressionDao.executeSearch(nameExp, studyEnvBundle.getStudyEnv().getId()); + + Assertions.assertEquals(1, resultsName.size()); + assertTrue(resultsName.stream().anyMatch(r -> r.getEnrollee().getId().equals(bundle2.enrollee().getId()))); } @Test @@ -658,7 +689,7 @@ public void testFamilyTerm(TestInfo info) { @Test @Transactional - public void testInclude(TestInfo info) { + public void testIncludeFamily(TestInfo info) { Enrollee enrollee = enrolleeFactory.buildPersisted(getTestName(info)); Family family = familyFactory.buildPersisted(getTestName(info), enrollee); @@ -689,4 +720,31 @@ public void testInclude(TestInfo info) { assertEquals(1, result.getFamilies().size()); assertEquals(family.getId(), result.getFamilies().get(0).getId()); } + + @Test + @Transactional + public void testIncludeUserData(TestInfo info) { + EnrolleeBundle bundle = enrolleeFactory.buildWithPortalUser(getTestName(info)); + Instant loginTime = Instant.now(); + bundle.portalParticipantUser().setLastLogin(loginTime); + portalParticipantUserService.update(bundle.portalParticipantUser()); + EnrolleeSearchExpression defaultExp = enrolleeSearchExpressionParser.parseRule(""); + + List results = enrolleeSearchExpressionDao.executeSearch(defaultExp, bundle.enrollee().getStudyEnvironmentId()); + assertEquals(1, results.size()); + EnrolleeSearchExpressionResult result = results.get(0); + assertThat(results.get(0).getPortalParticipantUser(), nullValue()); + assertThat(results.get(0).getParticipantUser(), nullValue()); + + EnrolleeSearchExpression includeExp = enrolleeSearchExpressionParser.parseRule( + "include({user.username}) and include({portalUser.lastLogin})" + ); + results = enrolleeSearchExpressionDao.executeSearch(includeExp, bundle.enrollee().getStudyEnvironmentId()); + + assertEquals(1, results.size()); + // we can't check exact equality since it goes to the DB, so instead just confirm seconds + assertThat(results.get(0).getPortalParticipantUser().getLastLogin().truncatedTo(ChronoUnit.MILLIS), + equalTo(loginTime.truncatedTo(ChronoUnit.MILLIS))); + assertThat(results.get(0).getParticipantUser().getUsername(), equalTo(bundle.participantUser().getUsername())); + } } diff --git a/core/src/test/java/bio/terra/pearl/core/service/search/EnrolleeSearchServiceTest.java b/core/src/test/java/bio/terra/pearl/core/service/search/EnrolleeSearchServiceTest.java index 6aeb32b712..de587de7a4 100644 --- a/core/src/test/java/bio/terra/pearl/core/service/search/EnrolleeSearchServiceTest.java +++ b/core/src/test/java/bio/terra/pearl/core/service/search/EnrolleeSearchServiceTest.java @@ -198,7 +198,7 @@ void testGetSearchFacetsForPortal(TestInfo info) { .map(val -> new QuestionChoice(val.name(), val.name())) .collect(Collectors.toList()); - Assertions.assertEquals(28, results.size()); + Assertions.assertEquals(30, results.size()); Map.ofEntries( Map.entry("profile.givenName", SearchValueTypeDefinition.builder().type(STRING).build()), Map.entry("profile.familyName", SearchValueTypeDefinition.builder().type(STRING).build()), @@ -231,6 +231,8 @@ void testGetSearchFacetsForPortal(TestInfo info) { Map.entry("enrollee.subject", SearchValueTypeDefinition.builder().type(BOOLEAN).build()), Map.entry("enrollee.consented", SearchValueTypeDefinition.builder().type(BOOLEAN).build()), Map.entry("enrollee.shortcode", SearchValueTypeDefinition.builder().type(STRING).build()), + Map.entry("portalUser.createdAt", SearchValueTypeDefinition.builder().type(INSTANT).build()), + Map.entry("portalUser.lastLogin", SearchValueTypeDefinition.builder().type(INSTANT).build()), Map.entry("age", SearchValueTypeDefinition.builder().type(NUMBER).build()), Map.entry("latestKit.status", SearchValueTypeDefinition.builder().type(STRING).choices(kitStatusChoices).build()) ).forEach((key, value) -> { diff --git a/core/src/test/java/bio/terra/pearl/core/service/search/expressions/EnrolleeSearchExpressionTest.java b/core/src/test/java/bio/terra/pearl/core/service/search/expressions/EnrolleeSearchExpressionTest.java index ee11868a13..8de9a9acdb 100644 --- a/core/src/test/java/bio/terra/pearl/core/service/search/expressions/EnrolleeSearchExpressionTest.java +++ b/core/src/test/java/bio/terra/pearl/core/service/search/expressions/EnrolleeSearchExpressionTest.java @@ -12,21 +12,26 @@ import bio.terra.pearl.core.model.participant.Enrollee; import bio.terra.pearl.core.model.participant.Family; import bio.terra.pearl.core.model.participant.Profile; +import bio.terra.pearl.core.model.search.EnrolleeSearchExpressionResult; import bio.terra.pearl.core.model.study.StudyEnvironment; import bio.terra.pearl.core.model.survey.Survey; import bio.terra.pearl.core.model.workflow.TaskStatus; import bio.terra.pearl.core.model.workflow.TaskType; import bio.terra.pearl.core.service.kit.pepper.PepperKitStatus; +import bio.terra.pearl.core.service.participant.PortalParticipantUserService; import bio.terra.pearl.core.service.rule.EnrolleeContext; import bio.terra.pearl.core.service.rule.EnrolleeContextService; import bio.terra.pearl.core.service.search.EnrolleeSearchContext; import bio.terra.pearl.core.service.search.EnrolleeSearchExpression; import bio.terra.pearl.core.service.search.EnrolleeSearchExpressionParser; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -59,6 +64,8 @@ class EnrolleeSearchExpressionTest extends BaseSpringBootTest { FamilyFactory familyFactory; @Autowired EnrolleeContextService enrolleeContextService; + @Autowired + PortalParticipantUserService portalParticipantUserService; @Test @Transactional @@ -420,6 +427,39 @@ public void testEnrolleeFields() { .build())); } + @Test + @Transactional + public void testPortalUser(TestInfo info) { + StudyEnvironmentBundle studyEnvBundle = studyEnvironmentFactory.buildBundle(getTestName(info), EnvironmentName.sandbox); + EnrolleeBundle bundle = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv()); + Instant loginTime = Instant.now(); + bundle.portalParticipantUser().setLastLogin(loginTime); + portalParticipantUserService.update(bundle.portalParticipantUser()); + + assertFalse(enrolleeSearchExpressionParser + .parseRule("{portalUser.lastLogin} < {user.createdAt}") + .evaluate( + EnrolleeSearchContext + .builder() + .enrollee(bundle.enrollee()) + .build())); + + + bundle = enrolleeFactory.buildWithPortalUser(getTestName(info), studyEnvBundle.getPortalEnv(), studyEnvBundle.getStudyEnv()); + loginTime = Instant.now().minusSeconds(3600); + bundle.portalParticipantUser().setLastLogin(loginTime); + portalParticipantUserService.update(bundle.portalParticipantUser()); + + assertTrue(enrolleeSearchExpressionParser + .parseRule("{portalUser.lastLogin} < {user.createdAt}") + .evaluate( + EnrolleeSearchContext + .builder() + .enrollee(bundle.enrollee()) + .build())); + } + + @Test public void testContains() { assertTrue(enrolleeSearchExpressionParser diff --git a/populate/src/main/java/bio/terra/pearl/populate/service/PortalParticipantUserPopulator.java b/populate/src/main/java/bio/terra/pearl/populate/service/PortalParticipantUserPopulator.java index 3bd3994b49..8f6159715d 100644 --- a/populate/src/main/java/bio/terra/pearl/populate/service/PortalParticipantUserPopulator.java +++ b/populate/src/main/java/bio/terra/pearl/populate/service/PortalParticipantUserPopulator.java @@ -15,6 +15,7 @@ import java.io.IOException; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -55,8 +56,10 @@ protected void preProcessDto(PortalParticipantUserPopDto popDto, PortalPopulateC .findOne(userDto.getUsername(), context.getEnvironmentName()); ParticipantUser user = existingUserOpt.orElseGet(() -> participantUserService.create(userDto)); if (userDto.getLastLoginHoursAgo() != null) { - user.setLastLogin(Instant.now().minusSeconds(userDto.getLastLoginHoursAgo() * 60 * 60)); + Instant loginTime = Instant.now().minus(userDto.getLastLoginHoursAgo(), ChronoUnit.HOURS); + user.setLastLogin(loginTime); participantUserService.update(user); + popDto.setLastLogin(loginTime); } PortalEnvironment portalEnvironment = portalEnvironmentService .findOne(context.getPortalShortcode(), context.getEnvironmentName()).get(); diff --git a/ui-admin/src/api/api.tsx b/ui-admin/src/api/api.tsx index 80b9701afe..3cb4e88386 100644 --- a/ui-admin/src/api/api.tsx +++ b/ui-admin/src/api/api.tsx @@ -18,6 +18,7 @@ import { PortalEnvironment, PortalEnvironmentConfig, PortalEnvironmentLanguage, + PortalParticipantUser, Profile, SiteContent, Study, @@ -78,6 +79,7 @@ export type EnrolleeSearchExpressionResult = { latestKit?: KitRequest, families: Family[] participantUser?: ParticipantUser + portalParticipantUser?: PortalParticipantUser } export type ExpressionSearchFacets = { [index: string]: SearchValueTypeDefinition } diff --git a/ui-admin/src/study/participants/participantList/ParticipantList.test.tsx b/ui-admin/src/study/participants/participantList/ParticipantList.test.tsx index e3f8072851..464212c9bc 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantList.test.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantList.test.tsx @@ -154,7 +154,7 @@ test('keyword search sends search api request', async () => { 'or {enrollee.shortcode} contains \'foo\' ' + 'or {family.shortcode} contains \'foo\') ' + 'and {enrollee.subject} = true ' + - 'and include({user.lastLogin})') + 'and include({user.username}) and include({portalUser.lastLogin})') }) test('allows the user to cycle pages', async () => { diff --git a/ui-admin/src/study/participants/participantList/ParticipantList.tsx b/ui-admin/src/study/participants/participantList/ParticipantList.tsx index c6e9071a60..c2c12f51fa 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantList.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantList.tsx @@ -32,7 +32,7 @@ function ParticipantList({ studyEnvContext }: {studyEnvContext: StudyEnvContextT } = useParticipantSearchState() const generateFullSearchExpression = () => { - const expressions: string[] = [searchExpression, 'include({user.lastLogin})'] + const expressions: string[] = [searchExpression, 'include({user.username})', 'include({portalUser.lastLogin})'] if (familyLinkageEnabled) { expressions.push('include({family.shortcode})') } diff --git a/ui-admin/src/study/participants/participantList/ParticipantListTable.tsx b/ui-admin/src/study/participants/participantList/ParticipantListTable.tsx index 53face2f89..654130865f 100644 --- a/ui-admin/src/study/participants/participantList/ParticipantListTable.tsx +++ b/ui-admin/src/study/participants/participantList/ParticipantListTable.tsx @@ -127,7 +127,7 @@ function ParticipantListTable({ meta: { columnType: 'instant' }, - accessorFn: row => row.participantUser?.lastLogin, + accessorFn: row => row.portalParticipantUser?.lastLogin, cell: info => instantToDefaultString(info.getValue() as unknown as number) }, { id: 'familyName', diff --git a/ui-core/src/types/user.ts b/ui-core/src/types/user.ts index b58842dc72..3f7276d630 100644 --- a/ui-core/src/types/user.ts +++ b/ui-core/src/types/user.ts @@ -25,6 +25,12 @@ export type ParticipantUser = { lastLogin: number } +export type PortalParticipantUser = { + id: string, + createdAt: string, + lastLogin: number +} + export type Enrollee = { id: string consented: boolean