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
76 changes: 76 additions & 0 deletions src/main/java/uk/ac/ngs/TaskConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2015 STFC
* Licensed 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 uk.ac.ngs;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import uk.ac.ngs.service.CertificateService;

@Configuration
@EnableScheduling
public class TaskConfig implements SchedulingConfigurer {
public static final Logger log = LoggerFactory.getLogger(TaskConfig.class);

private CertificateService certificateService;

public TaskConfig(CertificateService certificateService) {
this.certificateService = certificateService;
}


@Scheduled(cron = "${cert.expiry.reminder.cron:0 0 7 * * ?}")
public void runDailyCertificateExpiryReminderJob() {

log.info("Starting daily certificate expiry reminder job");

long startTime = System.currentTimeMillis();

try {
certificateService.sendCertificateExpiryReminders();

long durationMs = System.currentTimeMillis() - startTime;
log.info("Completed daily certificate expiry reminder job successfully in {} ms",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe a summary of number of SMTP mail pushed for 30 day and 7 day reminder as well so that we can spot the anomaly by comparing this number and expected number from DB. Thanks

@garaimanoj garaimanoj May 19, 2026

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.

Done
Summary for 7-day expiry reminder (processingDate=2026-05-19): total=1, sent=0, failed=1

durationMs);

} catch (Exception ex) {
log.error("Daily certificate expiry reminder job failed", ex);
throw ex;
}

}

@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(taskScheduler());
}


@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("cert-expiry-scheduler-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}
1 change: 1 addition & 0 deletions src/main/java/uk/ac/ngs/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public EmailService emailService() {
emailService.setEmailOnRaopRoleRequestApprovalTemplate("emailOnRaopRoleRequestApprovalTemplate.html");
emailService.setEmailOnRaopRoleRequestRejectionTemplate("emailOnRaopRoleRequestRejectionTemplate.html");
emailService.setEmailOnRoleChangeToUserTemplate("emailOnRoleChangeToUserTemplate.html");
emailService.setEmailUserCertExpiryReminderTemplate("emailUserCertExpiryReminderTemplate.html");
emailService.setBasePortalUrl(basePortalUrl);
return emailService;
}
Expand Down
49 changes: 49 additions & 0 deletions src/main/java/uk/ac/ngs/dao/JdbcCertificateDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -458,6 +461,52 @@
}


/**
* Retrieves all valid certificates that are expiring exactly in the given
* number of days.
*
* <p>
* This method is intended for reminder notifications (e.g. 7-day expiry
* reminder).
* A certificate is considered matching if its {@code notafter} timestamp falls
* within the full UTC day that is {@code daysToExpire} days from now.
* </p>
*
* @param daysToExpire number of days from today (e.g. 7 for a 7-day reminder)
* @return list of certificates expiring exactly in the specified number of days
*/
public List<CertificateRow> getValidCertificatesExpiringInDays(LocalDate processingDate, int daysToExpire) {

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").withZone(ZoneOffset.UTC);

LocalDate targetDate = processingDate.plusDays(daysToExpire);

// yyyyMMddHHmmss produces only digits → safe to parse as long
long startOfDay = Long.parseLong(
formatter.format(targetDate.atStartOfDay(ZoneOffset.UTC)));

Check notice

Code scanning / CodeQL

Missing catch of NumberFormatException Note

Potential uncaught 'java.lang.NumberFormatException'.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment on lines +485 to +486

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.

yyyyMMddHHmmss produces only digits → safe to parse as long


// yyyyMMddHHmmss produces only digits → safe to parse as long
long endOfDay = Long.parseLong(

Check notice

Code scanning / CodeQL

Missing catch of NumberFormatException Note

Potential uncaught 'java.lang.NumberFormatException'.
formatter.format(targetDate.plusDays(1)
.atStartOfDay(ZoneOffset.UTC)));
Comment on lines +489 to +491

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.

yyyyMMddHHmmss produces only digits → safe to parse as long


Map<String, Object> params = Map.of(
"startOfDay", startOfDay,
"endOfDay", endOfDay);

String sql = """
SELECT cert_key, 'data' as data, dn, cn, email, status, role, notafter
FROM certificate
WHERE status = 'VALID'
AND notafter >= :startOfDay
AND notafter < :endOfDay
ORDER BY notafter ASC
""";

return jdbcTemplate.query(sql, params, new CertificateRowMapper());
}


/**
* Build up the query using the given where by parameters in the map
* and return the query and the named parameter map for subsequent parameter-binding/execution.
Expand Down
77 changes: 77 additions & 0 deletions src/main/java/uk/ac/ngs/dao/JobExecutionDao.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright (C) 2015 STFC
* Licensed 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 uk.ac.ngs.dao;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

@Repository
public class JobExecutionDao {
private NamedParameterJdbcTemplate jdbcTemplate;
private static final Log log = LogFactory.getLog(JobExecutionDao.class);

public JobExecutionDao(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

public LocalDate getLastRunDate(String jobName) {

String sql = """
SELECT last_run_date
FROM job_execution_tracker
WHERE job_name = :jobName
""";

Map<String, Object> params = Map.of("jobName", jobName);

try {
LocalDate result = jdbcTemplate.queryForObject(sql, params,
(rs, rowNum) -> rs.getDate("last_run_date").toLocalDate());

log.info("Last run date for job '" + jobName + "' is " + result);
return result;

} catch (EmptyResultDataAccessException ex) {
log.warn("No last run date found for job '" + jobName + "'. Assuming first run.");
return null; // first run case
}
}

public void updateLastRunDate(String jobName, LocalDate lastRunDate) {
// This query inserts a new job record if it doesn’t exist,
// or updates its last run date if it already exists, using PostgreSQL’s UPSERT mechanism.
String sql = """
INSERT INTO job_execution_tracker (job_name, last_run_date)
VALUES (:jobName, :lastRunDate)
ON CONFLICT (job_name)
DO UPDATE SET last_run_date = EXCLUDED.last_run_date
""";

Map<String, Object> params = Map.of(
"jobName", jobName,
"lastRunDate", java.sql.Date.valueOf(lastRunDate));

int rows = jdbcTemplate.update(sql, params);

log.info("Updated last run date for job '" + jobName + "' to " + lastRunDate + " (rows affected: " + rows + ")");
}

}
78 changes: 78 additions & 0 deletions src/main/java/uk/ac/ngs/service/CertificateService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.springframework.validation.Errors;
import org.springframework.validation.MapBindingResult;
import uk.ac.ngs.common.MutableConfigParams;
import uk.ac.ngs.dao.JobExecutionDao;
import uk.ac.ngs.dao.JdbcCertificateDao;
import uk.ac.ngs.dao.RoleChangeRequestRepository;
import uk.ac.ngs.domain.CertificateRow;
Expand All @@ -29,6 +30,7 @@
import javax.inject.Inject;
import java.io.IOException;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.EnumMap;
import java.util.HashMap;
Expand All @@ -48,6 +50,7 @@
public class CertificateService {
private static final Log log = LogFactory.getLog(CertificateService.class);
private JdbcCertificateDao jdbcCertDao;
private JobExecutionDao jobExecutionDao;
private EmailService emailService;
private MutableConfigParams mutableConfigParams;
private RoleChangeRequestRepository roleChangeRequestRepository;
Expand Down Expand Up @@ -245,11 +248,86 @@ private void sendEmailNotificationOnRoleChangeRequest(CertificateRow targetCert,
this.emailService.sendUserOnRaopRoleRequest(requesterCN, targetCN, targetEmail);
}


/**
*
* Sends certificate expiry reminder emails for 7-day and 30-day intervals,
* including catch-up processing for any missed executions.
*
* <p>
* This method is intended to be executed by a scheduled job
* once per day.
* </p>
*
* <p>
* If the scheduler did not run on previous days (e.g. due to downtime),
* this method processes all missed dates from the last successful run up to
* today, ensuring no expiry reminders are skipped.
* </p>
*
*/
public void sendCertificateExpiryReminders() {
LocalDate today = LocalDate.now(ZoneOffset.UTC);

LocalDate lastRun = jobExecutionDao.getLastRunDate("CERT_EXPIRY_REMINDER");

// First time case
if (lastRun == null) {
lastRun = today.minusDays(1);
}

LocalDate processingDate = lastRun.plusDays(1);

while (!processingDate.isAfter(today)) {
sendRemindersForDaysToExpire(processingDate, 7);
sendRemindersForDaysToExpire(processingDate, 30);
processingDate = processingDate.plusDays(1);
}

jobExecutionDao.updateLastRunDate(
"CERT_EXPIRY_REMINDER", today);
}

private void sendRemindersForDaysToExpire(LocalDate processingDate, int daysToExpire) {

List<CertificateRow> certificates = jdbcCertDao.getValidCertificatesExpiringInDays(processingDate,
daysToExpire);

int totalCertificates = certificates.size();

if (totalCertificates > 0) {
log.info(totalCertificates + " certificate(s) are going to expire after " + daysToExpire + " days.");
} else {
log.info("No certificates are going to expire after " + daysToExpire + " days.");
}

int successCount = 0;
int failureCount = 0;

for (CertificateRow cert : certificates) {
log.info("Sending " + daysToExpire + "-days expiry reminder for cert [" + cert.getCert_key() + "] to "
+ cert.getEmail());
if (this.emailService.sendEmailReminderToUserOnCertExpiry(cert, daysToExpire)) {
successCount++;
} else {
failureCount++;
}
}
log.info("Summary for " + daysToExpire + "-day expiry reminder (processingDate=" + processingDate + "): total="
+ totalCertificates + ", sent=" + successCount + ", failed=" + failureCount);
}


@Inject
public void setJdbcCertificateDao(JdbcCertificateDao jdbcCertDao) {
this.jdbcCertDao = jdbcCertDao;
}

@Inject
public void setJobExecutionDao(JobExecutionDao certExpiryReminderJobExecutionDao) {
this.jobExecutionDao = certExpiryReminderJobExecutionDao;
}

@Inject
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
Expand Down
Loading
Loading