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);
		}
	}

}