ApiTokenExpiryNotifier.java
/*
* Copyright 2026 Global Crop Diversity Trust
*
* 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 org.genesys.server.service.worker;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.genesys.blocks.tokenauth.model.ApiToken;
import org.genesys.blocks.tokenauth.model.QApiToken;
import org.genesys.server.model.genesys.OAuthClient;
import org.genesys.server.model.impl.User;
import org.genesys.server.service.ArticleService;
import org.genesys.server.service.ArticleTranslationService;
import org.genesys.server.service.ContentService;
import org.genesys.server.service.EMailService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.querydsl.jpa.impl.JPAQueryFactory;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.apache.commons.lang3.StringUtils;
/**
* Scheduled job that notifies API token owners 7 days before expiration,
* 1 day before expiration, and 1 day after expiration.
*/
@Slf4j
@Component
public class ApiTokenExpiryNotifier {
@Autowired
private ArticleService articleService;
@Autowired
private ContentService contentService;
@Autowired
private EMailService emailService;
@Autowired
private JPAQueryFactory jpaQueryFactory;
@Value("${base.url}")
private String serverUrl;
@Value("${mail.helpdesk}")
private String helpdeskEmail;
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd-MMM-yyyy", Locale.ENGLISH);
// Every day at 08:00 local time
@Scheduled(cron = "0 0 8 * * *")
@SchedulerLock(name = "org.genesys.server.service.worker.ApiTokenExpiryNotifier", lockAtLeastFor = "PT15M", lockAtMostFor = "PT30M")
// @Scheduled(fixedDelayString = "PT30M") // For tests
public void notifyExpiringTokens() {
ArticleTranslationService.TranslatedArticle article = articleService.getGlobalArticle(ContentService.SMTP_API_TOKEN_EXPIRES_NOTIFICATION, Locale.ENGLISH);
if (article == null) {
log.warn("Global article not found: {}", ContentService.SMTP_API_TOKEN_EXPIRES_NOTIFICATION);
return;
}
LocalDate targetDate = LocalDate.now().plusDays(7);
Instant from = targetDate.atStartOfDay(ZoneOffset.UTC).toInstant();
Instant to = targetDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
log.info("Looking for API tokens expiring between {} and {}", from, to);
var qApiToken = QApiToken.apiToken;
List<ApiToken> tokens = jpaQueryFactory.selectFrom(qApiToken)
.where(qApiToken.active.isTrue().and(qApiToken.expires.goe(from)).and(qApiToken.expires.lt(to)))
.fetch();
String notificationText = "will expire on <strong>" + DATE_FMT.format(ZonedDateTime.ofInstant(to, ZoneOffset.UTC)) + "</strong>";
if (!tokens.isEmpty()) {
sendNotification(tokens, article, notificationText);
}
targetDate = LocalDate.now().plusDays(1);
from = targetDate.atStartOfDay(ZoneOffset.UTC).toInstant();
to = targetDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
log.info("Looking for API tokens expiring between {} and {}", from, to);
tokens = jpaQueryFactory.selectFrom(qApiToken)
.where(qApiToken.active.isTrue().and(qApiToken.expires.goe(from)).and(qApiToken.expires.lt(to)))
.fetch();
notificationText = "expires <strong>tomorrow</strong>";
if (!tokens.isEmpty()) {
sendNotification(tokens, article, notificationText);
}
targetDate = LocalDate.now().minusDays(1);
from = targetDate.atStartOfDay(ZoneOffset.UTC).toInstant();
to = targetDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
log.info("Looking for expired API tokens from {} to {}", from, to);
tokens = jpaQueryFactory.selectFrom(qApiToken)
.where(qApiToken.active.isTrue().and(qApiToken.expires.goe(from)).and(qApiToken.expires.lt(to)))
.fetch();
notificationText = "has expired and can no longer be used";
if (!tokens.isEmpty()) {
sendNotification(tokens, article, notificationText);
}
}
private void sendNotification(List<ApiToken> tokens, ArticleTranslationService.TranslatedArticle article, String notificationText) {
for (ApiToken token : tokens) {
String recipient = null;
var sid = token.getSid();
if (sid instanceof User) {
recipient = ((User) sid).getEmail();
} else if (sid instanceof OAuthClient) {
recipient = ((OAuthClient) sid).getContactEmail();
}
if (StringUtils.isBlank(recipient)) {
// When no contact email then send notification to helpdesk
recipient = helpdeskEmail;
}
Map<String, Object> root = new HashMap<>();
root.put("title", token.getLabel());
root.put("serverUrl", serverUrl);
root.put("helpdesk", helpdeskEmail);
root.put("notification", notificationText);
String body = article.getEntity().getBody();
String title = article.getEntity().getTitle();
String mailBody = contentService.processTemplate(body, root);
emailService.sendMail(title, mailBody, new String[] { helpdeskEmail }, null, recipient);
}
}
}