diff --git a/modules/patient-portal/pom.xml b/modules/patient-portal/pom.xml
index 8e32e5690f..b451e35823 100644
--- a/modules/patient-portal/pom.xml
+++ b/modules/patient-portal/pom.xml
@@ -173,6 +173,11 @@
cards-http-requests
${project.version}
+
+ ${project.groupId}
+ cards-data-model-forms-api
+ ${project.version}
+
org.apache.sling
org.apache.sling.servlets.annotations
diff --git a/modules/patient-portal/src/main/java/io/uhndata/cards/patients/internal/PatientInformationCleanupScheduler.java b/modules/patient-portal/src/main/java/io/uhndata/cards/patients/internal/PatientInformationCleanupScheduler.java
new file mode 100644
index 0000000000..cdac971c72
--- /dev/null
+++ b/modules/patient-portal/src/main/java/io/uhndata/cards/patients/internal/PatientInformationCleanupScheduler.java
@@ -0,0 +1,81 @@
+/*
+ * 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.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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.uhndata.cards.forms.api.FormUtils;
+import io.uhndata.cards.resolverProvider.ThreadResourceResolverProvider;
+
+/**
+ * Schedule the cleanup of Patient information data every midnight.
+ *
+ * @version $Id$
+ * @since 0.9.2
+ */
+@Component(immediate = true)
+public class PatientInformationCleanupScheduler
+{
+ /** Default log. */
+ private static final Logger LOGGER = LoggerFactory.getLogger(PatientInformationCleanupScheduler.class);
+
+ private static final String SCHEDULER_JOB_NAME = "PatientInformationCleanup";
+
+ /** Provides access to resources. */
+ @Reference
+ private ResourceResolverFactory resolverFactory;
+
+ /** For sharing the resource resolver with other services. */
+ @Reference
+ private ThreadResourceResolverProvider rrp;
+
+ /** The utils for working with form data. */
+ @Reference
+ private FormUtils formUtils;
+
+ /** The scheduler for rescheduling jobs. */
+ @Reference
+ private Scheduler scheduler;
+
+ @Activate
+ protected void activate(final ComponentContext componentContext) throws Exception
+ {
+ 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 PatientInformationCleanupTask(this.resolverFactory, this.rrp,
+ this.formUtils);
+ this.scheduler.schedule(cleanupJob, options);
+ } catch (final Exception e) {
+ LOGGER.error("PatientInformationCleanup failed to schedule: {}", e.getMessage(), e);
+ }
+ }
+}
diff --git a/modules/patient-portal/src/main/java/io/uhndata/cards/patients/internal/PatientInformationCleanupTask.java b/modules/patient-portal/src/main/java/io/uhndata/cards/patients/internal/PatientInformationCleanupTask.java
new file mode 100644
index 0000000000..d8295d2c9b
--- /dev/null
+++ b/modules/patient-portal/src/main/java/io/uhndata/cards/patients/internal/PatientInformationCleanupTask.java
@@ -0,0 +1,189 @@
+/*
+ * 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.patients.internal;
+
+import java.time.ZonedDateTime;
+import java.util.Iterator;
+import java.util.Map;
+
+import javax.jcr.Node;
+import javax.jcr.NodeIterator;
+import javax.jcr.PathNotFoundException;
+import javax.jcr.RepositoryException;
+import javax.jcr.ValueFormatException;
+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.auth.token.impl.CardsTokenImpl;
+import io.uhndata.cards.forms.api.FormUtils;
+import io.uhndata.cards.resolverProvider.ThreadResourceResolverProvider;
+
+/**
+ * Periodically clears last_name, first_name, email from any Patient information form of patient subjects
+ * for whom the last Visit information form has surveys_submitted set to true or the token associated with the visit
+ * has expired.
+ *
+ * @version $Id$
+ * @since 0.9.2
+ */
+public class PatientInformationCleanupTask implements Runnable
+{
+ /** Default log. */
+ private static final Logger LOGGER = LoggerFactory.getLogger(PatientInformationCleanupTask.class);
+
+ /** Provides access to resources. */
+ private final ResourceResolverFactory resolverFactory;
+
+ /** For sharing the resource resolver with other services. */
+ private final ThreadResourceResolverProvider rrp;
+
+ private final FormUtils formUtils;
+
+ /**
+ * @param resolverFactory a valid ResourceResolverFactory providing access to resources
+ * @param rrp ThreadResourceResolverProvider sharing the resource resolver with other services
+ * @param formUtils for working with form data
+ */
+ PatientInformationCleanupTask(final ResourceResolverFactory resolverFactory,
+ final ThreadResourceResolverProvider rrp, final FormUtils formUtils)
+ {
+ this.resolverFactory = resolverFactory;
+ this.rrp = rrp;
+ this.formUtils = formUtils;
+ }
+
+ @Override
+ @SuppressWarnings("checkstyle:CyclomaticComplexity")
+ 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 patientInformationQuestionnaire =
+ (String) resolver.getResource("/Questionnaires/Patient information").getValueMap().get("jcr:uuid");
+ final String visitInformationQuestionnaire =
+ (String) resolver.getResource("/Questionnaires/Visit information").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"
+ + " where"
+ // link to the correct Visit Information questionnaire
+ + " visitInformation.questionnaire = '%1$s'"
+ // link to the correct Patient Information questionnaire
+ + " and dataForm.questionnaire = '%2$s'",
+ visitInformationQuestionnaire, patientInformationQuestionnaire),
+ Query.JCR_SQL2);
+ resources.forEachRemaining(form -> {
+ try {
+ Node formNode = form.adaptTo(Node.class);
+ if (!canDeleteInformation(formNode, resolver, visitInformationQuestionnaire,
+ patientInformationQuestionnaire)) {
+ return;
+ }
+ final NodeIterator children = formNode.getNodes();
+ while (children.hasNext()) {
+ final Node child = children.nextNode();
+ if (child.isNodeType("cards:Answer")) {
+ final String name = child.getProperty("question").getNode().getName();
+ if ("first_name".equals(name) || "last_name".equals(name) || "email".equals(name)) {
+ child.remove();
+ }
+ }
+ }
+ } catch (final RepositoryException e) {
+ LOGGER.warn("Failed to delete patient information {}: {}", form.getPath(), e.getMessage());
+ }
+ });
+ resolver.commit();
+ } catch (final LoginException e) {
+ LOGGER.warn("Invalid setup, service rights not set up for the patient information cleanup task");
+ } catch (final PersistenceException e) {
+ LOGGER.warn("Failed to delete patient information: {}", e.getMessage());
+ } finally {
+ if (mustPopResolver) {
+ this.rrp.pop();
+ }
+ }
+ }
+
+ private boolean canDeleteInformation(final Node form, final ResourceResolver resolver, final String visitQ,
+ final String patientQ) throws ValueFormatException, PathNotFoundException, RepositoryException
+ {
+ // run query to get all associated Visit information forms sorted by time property
+ Iterator resources = resolver.findResources(String.format(
+ // select the data forms
+ "select distinct visitInformation.*"
+ + " from [cards:Form] as visitInformation"
+ // belonging to a visit
+ + " inner join [cards:Form] as dataForm on visitInformation.subject = dataForm.subject"
+ + " where"
+ // link to the correct Visit Information questionnaire
+ + " visitInformation.questionnaire = '%1$s'"
+ // link to the correct Patient Information questionnaire
+ + " and dataForm.questionnaire = '%2$s'"
+ + " and dataForm.[jcr:uuid] = '%3$s'"
+ + " order by visitInformation.time desc",
+ visitQ, patientQ, form.getProperty("jcr:uuid").getString()),
+ Query.JCR_SQL2);
+
+ if (!resources.hasNext()) {
+ return false;
+ }
+ // get the last visit and see if it's surveys_submitted = true
+ final Node visit = resources.next().adaptTo(Node.class);
+ if (visit == null) {
+ return false;
+ }
+ if (visit.hasProperty("surveys_submitted") && visit.getProperty("surveys_submitted").getBoolean()) {
+ return true;
+ }
+
+ final Node visitSubjectPath = this.formUtils.getSubject(visit);
+ // run the query to get an expired token associated with patient name and visit
+ resources = resolver.findResources(String.format(
+ "select * from [cards:Token]"
+ + " where"
+ + " [" + CardsTokenImpl.TOKEN_ATTRIBUTE_EXPIRY + "] < '%1$s'"
+ + " and [cards:sessionSubject] = '%2$s'",
+ ZonedDateTime.now(), visitSubjectPath.getPath()),
+ Query.JCR_SQL2);
+ if (resources.hasNext()) {
+ return true;
+ }
+
+ return false;
+ }
+}