diff --git a/prems-resources/backend/pom.xml b/prems-resources/backend/pom.xml index eb6bbe22ac..735a4f0f12 100644 --- a/prems-resources/backend/pom.xml +++ b/prems-resources/backend/pom.xml @@ -66,5 +66,13 @@ org.osgi.framework 1.9.0 + + org.osgi + org.osgi.service.component + + + org.osgi + org.osgi.service.component.annotations + diff --git a/prems-resources/backend/src/main/java/io/uhndata/cards/prems/patients/internal/SubmittedFormsCleanupScheduler.java b/prems-resources/backend/src/main/java/io/uhndata/cards/prems/patients/internal/SubmittedFormsCleanupScheduler.java new file mode 100644 index 0000000000..d3acb3584b --- /dev/null +++ b/prems-resources/backend/src/main/java/io/uhndata/cards/prems/patients/internal/SubmittedFormsCleanupScheduler.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.uhndata.cards.prems.patients.internal; + +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.apache.sling.commons.scheduler.ScheduleOptions; +import org.apache.sling.commons.scheduler.Scheduler; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.Designate; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.uhndata.cards.resolverProvider.ThreadResourceResolverProvider; + +/** + * Automatically delete submitted survey responses older than max age. + * + * @version $Id$ + * @since 0.9.6 + */ +@Designate(ocd = SubmittedFormsCleanupScheduler.Config.class, factory = true) +@Component(immediate = true) +public class SubmittedFormsCleanupScheduler +{ + /** Default log. */ + private static final Logger LOGGER = LoggerFactory.getLogger(SubmittedFormsCleanupScheduler.class); + + private static final String SCHEDULER_JOB_NAME = "SubmittedFormsCleanup"; + + /** Provides access to resources. */ + @Reference + private ResourceResolverFactory resolverFactory; + + /** For sharing the resource resolver with other services. */ + @Reference + private ThreadResourceResolverProvider rrp; + + /** The scheduler for rescheduling jobs. */ + @Reference + private Scheduler scheduler; + + @ObjectClassDefinition(name = "Submitted survey responses cleanup", + description = "Automatically delete submitted survey responses older than one max age") + public static @interface Config + { + /** Default value of how long the submissions can be kept in the database in days. */ + int MAX_AGE = 365; + + @AttributeDefinition( + name = "Max age of submitted survey responses", + description = "Days of how long submissions can be kept in the database") + int maxAgeDays() default MAX_AGE; + } + + @Activate + private void activate(final Config config, final ComponentContext componentContext) + { + try { + // Every night at midnight + final ScheduleOptions options = this.scheduler.EXPR("0 0 0 * * ? *"); + options.name(SCHEDULER_JOB_NAME); + options.canRunConcurrently(false); + + final Runnable cleanupJob = new SubmittedFormsCleanupTask(this.resolverFactory, this.rrp, + config.maxAgeDays()); + this.scheduler.schedule(cleanupJob, options); + } catch (final Exception e) { + LOGGER.error("UnsubmittedFormsCleanup failed to schedule: {}", e.getMessage(), e); + } + } +} diff --git a/prems-resources/backend/src/main/java/io/uhndata/cards/prems/patients/internal/SubmittedFormsCleanupTask.java b/prems-resources/backend/src/main/java/io/uhndata/cards/prems/patients/internal/SubmittedFormsCleanupTask.java new file mode 100644 index 0000000000..5712a602d8 --- /dev/null +++ b/prems-resources/backend/src/main/java/io/uhndata/cards/prems/patients/internal/SubmittedFormsCleanupTask.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.uhndata.cards.prems.patients.internal; + +import java.time.ZonedDateTime; +import java.util.Iterator; +import java.util.Map; + +import javax.jcr.query.Query; + +import org.apache.sling.api.resource.LoginException; +import org.apache.sling.api.resource.PersistenceException; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.api.resource.ResourceResolverFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.uhndata.cards.resolverProvider.ThreadResourceResolverProvider; + +/** + * Periodically delete submitted survey responses older than max age. + * + * @version $Id$ + * @since 0.9.6 + */ +public class SubmittedFormsCleanupTask implements Runnable +{ + + /** Default log. */ + private static final Logger LOGGER = LoggerFactory.getLogger(SubmittedFormsCleanupTask.class); + + /** Provides access to resources. */ + private final ResourceResolverFactory resolverFactory; + + /** For sharing the resource resolver with other services. */ + private final ThreadResourceResolverProvider rrp; + + private final int maxAgeDays; + + /** + * @param resolverFactory a valid ResourceResolverFactory providing access to resources + * @param patientAccessConfiguration details on patient authentication for token lifetime purposes + * @param maxAgeDays OSGi config defines days of how long submissions can be kept in the database + */ + SubmittedFormsCleanupTask(final ResourceResolverFactory resolverFactory, final ThreadResourceResolverProvider rrp, + final int maxAgeDays) + { + this.resolverFactory = resolverFactory; + this.rrp = rrp; + this.maxAgeDays = maxAgeDays; + } + + @Override + public void run() + { + boolean mustPopResolver = false; + try (ResourceResolver resolver = this.resolverFactory + .getServiceResourceResolver(Map.of(ResourceResolverFactory.SUBSERVICE, "VisitFormsPreparation"))) { + this.rrp.push(resolver); + mustPopResolver = true; + // Gather the needed UUIDs to place in the query + final String visitInformationQuestionnaire = + (String) resolver.getResource("/Questionnaires/Visit information").getValueMap().get("jcr:uuid"); + final String submitted = (String) resolver + .getResource("/Questionnaires/Visit information/surveys_submitted").getValueMap().get("jcr:uuid"); + + // Query: + final Iterator resources = resolver.findResources(String.format( + // select the data forms + "select distinct dataForm.*" + + " from [cards:Form] as dataForm" + // belonging to a visit + + " inner join [cards:Form] as visitInformation on visitInformation.subject = dataForm.subject" + + " inner join [cards:Answer] as submitted on isdescendantnode(submitted, visitInformation)" + + " where" + // link to the correct Visit Information questionnaire + + " visitInformation.questionnaire = '%1$s'" + // the data form was last modified by the patient before the allowed timeframe + + " and dataForm.[jcr:lastModified] < '%2$s'" + // the visit is submitted + + " and submitted.question = '%3$s'" + + " and submitted.value = 1" + // exclude the Visit Information form itself + + " and dataForm.questionnaire <> '%1$s'", + visitInformationQuestionnaire, ZonedDateTime.now().minusDays(this.maxAgeDays), submitted), + Query.JCR_SQL2); + resources.forEachRemaining(form -> { + try { + resolver.delete(form); + } catch (final PersistenceException e) { + LOGGER.warn("Failed to delete expired survey form {}: {}", form.getPath(), e.getMessage()); + } + }); + resolver.commit(); + } catch (final LoginException e) { + LOGGER.warn("Invalid setup, service rights not set up for the expired survey forms cleanup task"); + } catch (final PersistenceException e) { + LOGGER.warn("Failed to delete expired survey responses forms: {}", e.getMessage()); + } finally { + if (mustPopResolver) { + this.rrp.pop(); + } + } + } +}