ApplicationStartup.java

/*
 * Copyright 2019 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.gringlobal.spring;

import static org.gringlobal.service.LanguageService.*;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.persistence.NonUniqueResultException;
import javax.transaction.Transactional;

import com.querydsl.jpa.impl.JPAQueryFactory;

import liquibase.changelog.DatabaseChangeLog;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.genesys.blocks.oauth.model.OAuthClient;
import org.genesys.blocks.oauth.model.OAuthRole;
import org.genesys.blocks.oauth.persistence.OAuthClientRepository;
import org.genesys.blocks.oauth.service.OAuthClientService;
import org.genesys.blocks.security.model.AclSid;
import org.genesys.blocks.security.serialization.Permissions;
import org.genesys.blocks.security.service.CustomAclService;
import org.genesys.filerepository.InvalidRepositoryFileDataException;
import org.genesys.filerepository.InvalidRepositoryPathException;
import org.genesys.filerepository.NoSuchRepositoryFileException;
import org.genesys.filerepository.model.RepositoryFile;
import org.genesys.filerepository.model.RepositoryFolder;
import org.genesys.filerepository.service.RepositoryService;
import org.gringlobal.custom.liquibase.ProgrammableChangeSet;
import org.gringlobal.custom.liquibase.ProgrammableLiquibase;
import org.gringlobal.model.Inventory;
import org.gringlobal.model.InventoryExtra;
import org.gringlobal.model.InventoryMaintenancePolicy;
import org.gringlobal.model.QInventory;
import org.gringlobal.model.QInventoryExtra;
import org.gringlobal.model.QInventoryMaintenancePolicy;
import org.gringlobal.model.QInventoryViability;
import org.gringlobal.model.QSysUser;
import org.gringlobal.model.SeedInventoryExtra;
import org.gringlobal.model.Site;
import org.gringlobal.model.SysUser;
import org.gringlobal.model.community.CommunityAppSettings;
import org.gringlobal.model.community.CommunityCodeValues;
import org.gringlobal.model.community.QSecuredAction;
import org.gringlobal.model.community.SecuredAction;
import org.gringlobal.model.community.SecurityAction;
import org.gringlobal.model.notification.NotificationSchedule;
import org.gringlobal.model.security.UserRole;
import org.gringlobal.notification.KPINotifications;
import org.gringlobal.notification.action.NotificationMessageEmailSender;
import org.gringlobal.persistence.InventoryMaintenancePolicyRepository;
import org.gringlobal.persistence.SiteRepository;
import org.gringlobal.persistence.SysGroupRepository;
import org.gringlobal.persistence.community.SecuredActionRepository;
import org.gringlobal.service.AccessionService;
import org.gringlobal.service.AppSettingsService;
import org.gringlobal.service.CodeValueService;
import org.gringlobal.service.DataviewServices.SysDataviewService;
import org.gringlobal.service.InventoryExtraService;
import org.gringlobal.service.InventoryService;
import org.gringlobal.service.JasperReportService;
import org.gringlobal.service.LanguageService;
import org.gringlobal.service.NotificationScheduleService;
import org.gringlobal.service.TableServices.SysTableFieldService;
import org.gringlobal.service.TransientMessageService;
import org.gringlobal.service.UserService;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.DependsOn;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import com.google.common.collect.Lists;

/**
 * Run things at startup and after application context is initialized.
 */
@Component
@DependsOn({ "currentApplicationContext" })
@Slf4j
public class ApplicationStartup implements InitializingBean {
	public static final String SID_GROUP_ADMINS = "GROUP_ADMINS";

	private static final String CHANGESET_AUTHOR = "GGCE"; // DO NOT MODIFY

	@Value("${default.oauthclient.clientId}")
	private String defaultOAuthClientId;

	@Value("${default.oauthclient.clientSecret}")
	private String defaultOAuthClientSecret;

	@Value("${base.url}")
	private String baseUrl;

	@Value("${frontend.url}")
	private String frontendUrl;

	@Autowired
	private DataSource dataSource;
	@Autowired
	private JPAQueryFactory jpaQueryFactory;

	@Autowired
	private SecuredActionRepository securedActionRepository;

	@Autowired
	private OAuthClientRepository oauthClientRepository;
	@Autowired
	private OAuthClientService oauthService;

	@Autowired
	private InventoryMaintenancePolicyRepository inventoryMaintPolicyRepository;
	@Autowired
	private UserService userService;
	@Autowired
	private CustomAclService aclService;
	@Autowired
	private RepositoryService repositoryService;
	@Autowired
	private SysGroupRepository sysGroupRepository;

	@Autowired
	private SiteRepository siteRepository;

	@Autowired
	private CodeValueService codeValueService;
	@Autowired
	private AppSettingsService appSettingsService;

	@Autowired
	private AccessionService accessionService;
	@Autowired
	private InventoryService inventoryService;

	@Autowired
	private LanguageService languageService;

	@Autowired
	private TransientMessageService transientMessageService;

	@Autowired
	private PasswordEncoder passwordEncoder;

	@Autowired
	private InventoryExtraService inventoryExtraService;


	@Autowired
	private NotificationScheduleService notificationScheduleService;

	@Autowired
	private SysDataviewService sysDataviewService;
	@Autowired
	private SysTableFieldService sysTableFieldService;

	@Autowired
	private OAuthClientService oAuthClientService;


	/**
	 * Things to run immediately
	 */
	@Override
	public void afterPropertiesSet() throws Exception {
		startup();
	}

	/**
	 * Startup.
	 */
	@Transactional
	void startup() throws Exception {
		ensure1OAuthClient();
		ensureSystemInventoryPolicy();
		ensureAuthoritySIDs();
		ensureAcl();
		ensureGGCESecurity();

		ensureCommunityCodeValues(); // Add missing community CodeValues
		ensureCommunityAppSettings(); // Add missing community AppSettings

		// check cooperator for sysUsers
		checkSysUserCooperator();

		/**
		 * One-time upgrades
		 */
		var stopWatch = StopWatch.createStarted();
		DatabaseChangeLog databaseChangeLog = new DatabaseChangeLog();
		databaseChangeLog.setLogicalFilePath("ApplicationStartup"); // DO NOT MODIFY

		// To run on every startup, set alwaysRun to true.
		// To re-run a change once (after code update), update the #id.
		// IMPORTANT: ID + filePath + author make a changeset unique!
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1662789283001", "ApplicationStartup.createRepositoryFolder_AIA", this::createRepositoryFolder_AIA, "Create default folder for AccessionInvAttach", "add '/AIA' repository folder", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1662789284001", "ApplicationStartup.addSysLangMCPD", this::addSysLangMCPD, "Add MCPD language", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1724397839000", "ApplicationStartup.upgradeDataviewsForGGCE", this::upgradeDataviewsForGGCE, "Upgrade GG dataviews to GGCE", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1662789284003", "ApplicationStartup.installJasperTemplate_InventoryLabels", this::installJasperTemplate_InventoryLabels, "Install inventoryLabels.jrxml", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		// November 2022
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1669369126182", "ApplicationStartup.generateSeedInventoryExtra", this::generateSeedInventoryExtra, "Generate SeedInventoryExtra for last completed InventoryViability", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		// May 2023
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1683170549000", "ApplicationStartup.ensureAccessionAndInventoryNumbers", this::ensureAccessionAndInventoryNumbers, "Generate accessionNumbers and inventoryNumbers", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		// July 2023
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1688985547646", "ApplicationStartup.ensureSystemInventories", this::ensureSystemInventories, "Generate missing system inventories for accessions", "", "", "", true, CHANGESET_AUTHOR, databaseChangeLog));
		// April 2024
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1712052420998", "ApplicationStartup.convertScheduledMethodsToNotificationSchedule", this::convertScheduledMethodsToNotificationSchedule, "Convert scheduled notification methods to NotificationSchedule", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		// May 2024
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1715090120046", "ApplicationStartup.addPermissionOnViabilityAttachmentFolderForAllUsers", this::addPermissionOnViabilityAttachmentFolderForAllUsers, "Add permissions on ViabilityAttachment folder for all users", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		// June 2024
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1717826756000", "ApplicationStartup.addActionNotificationProcessor", this::addActionNotificationProcessor, "Register action notifications processor", "", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));
		// Nov 2024
		databaseChangeLog.addChangeSet(new ProgrammableChangeSet("1730741847000", "ApplicationStartup.addOrUpdateFieldBookClient", this::addOrUpdateFieldBookClient, "Register Fieldbook app", "Add or update client for Fieldbook app", "", "", false, CHANGESET_AUTHOR, databaseChangeLog));

		ProgrammableLiquibase programmableLiquibase = new ProgrammableLiquibase(databaseChangeLog);
		programmableLiquibase.setDataSource(dataSource);

		stopWatch.split();
		log.warn("Prepared programmable Liquibase changes in {}ms", stopWatch.getSplitTime());
		stopWatch.unsplit();

		programmableLiquibase.doUpgrades();
		stopWatch.stop();
		log.warn("Executed programmable Liquibase changes in {}ms", stopWatch.getTime(TimeUnit.MILLISECONDS));

		checkParentSystemInventory();
	}

	private void addSysLangMCPD() {
		try {
			asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> languageService.ensureSysLang(MCPD_IETF_TAG, "MCPD", "MCPD", null)));
		} catch (Throwable e) {
			log.error("Could not create SysLang MCPD: {}", e.getMessage(), e);
		}
	}

	private void ensureAccessionAndInventoryNumbers() throws Exception {
		asAdmin(() -> {
			accessionService.assignMissingAccessionNumbers();
			inventoryService.assignMissingInventoryNumbers(); 
			return true; 
		});
	}

	private void ensureSystemInventories() throws Exception {
		asAdmin(() -> {
			inventoryService.ensureSystemInventories();
			return true;
		});
	}

	/**
	 * Make sure we have all the required CodeValues
	 */
	private void ensureCommunityCodeValues() {

		try {
			asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> {
				CommunityCodeValues.listCommunityCodeValues().forEach((ccv) -> codeValueService.ensureCodeValue(ccv.groupName, ccv.value, ccv.title, ccv.description));
				return true;
			}));
		} catch (Throwable e) {
			log.error("Could not set up CodeValue: {}", e.getMessage(), e);
		}
	}

	/**
	 * Make sure we have all the required AppSettings
	 */
	private void ensureCommunityAppSettings() {
		try {
			asAdmin(() -> {
				CommunityAppSettings.listCommunityAppSettings().forEach((setting) -> {
					try {
						appSettingsService.ensureAppSetting(setting.categoryTag, setting.name, setting.value);
					} catch (IncorrectResultSizeDataAccessException | NonUniqueResultException e) {
						log.warn("Could not set up AppSetting {} with categoryTag={}: {}", setting.name, setting.categoryTag, e.getMessage());
					}
				});
				return true;
			});
		} catch (Throwable e) {
			log.error("Could not set up AppSetting: {}", e.getMessage(), e);
		}
	}

	private void ensureGGCESecurity() {
		try {
			asAdmin(() -> {
				var groupAdmins = aclService.ensureAuthoritySid(SID_GROUP_ADMINS);

				// Each security action requires an entry
				for (SecurityAction action : SecurityAction.values()) {
					var securedAction = securedActionRepository.getByActionAndSite(action.name(), null);
					if (securedAction == null) {
						log.warn("Registering Security action {}", action);
						securedAction = new SecuredAction();
						securedAction.setAction(action.name());
						securedActionRepository.save(securedAction);
						aclService.createOrUpdatePermissions(securedAction, groupAdmins); // this doesn't do much for groupAdmins
					}
				}
				return true;
			});
		} catch (Throwable e) {
			log.error("Could not set up ACL: {}", e.getMessage(), e);
		}

		// Remove invalid actions
		try {
			asAdmin(() -> {
				var validActions = Stream.of(SecurityAction.values()).map(Enum::name).collect(Collectors.toList());
				var invalidActions = securedActionRepository.findAll(QSecuredAction.securedAction.action.notIn(validActions));
				invalidActions.forEach(invalidAction -> {
					log.warn("Removing SecuredAction with invalid action code: {} site={}", invalidAction.getAction(), invalidAction.getSite());
				});
				securedActionRepository.deleteAll(invalidActions);
				return true;
			});
		} catch (Throwable e) {
			log.error("Could not delete invalid actions", e);
		}

		// GROUP_ADMINS needs all permissions
		try {
			asAdmin(() -> {
				var groupAdmins = aclService.ensureAuthoritySid(SID_GROUP_ADMINS);

				// Each security action requires an entry
				for (SecurityAction action : SecurityAction.values()) {
					var securedAction = securedActionRepository.getByActionAndSite(action.name(), null);
					if (securedAction != null) {
						log.info("Allowing {} all permissions on Security action {}", groupAdmins.getSid(), action);
						aclService.setPermissions(securedAction, groupAdmins, new Permissions().grantAll());
					}
				}
				return true;
			});
		} catch (Throwable e) {
			log.error("Could not set up ACL: {}", e.getMessage(), e);
		}
	}

	private void ensureAcl() {

		try {
			asAdmin(() -> {
				// Sites
				aclService.createOrUpdatePermissions(Site.ACL_CLASS_IDENTITY);
				aclService.makePubliclyReadable(Site.ACL_CLASS_IDENTITY, true);

				siteRepository.findAll().forEach((site) -> {
//					SysUser user = userService.getUserForCooperator(site.getOwnedBy());
					// FIXME Should be introduced method getUserForAclSid() ???
					SysUser user = null;
					if (site.getOwnedBy() instanceof SysUser) {
						user = (SysUser) site.getOwnedBy();
					}
					if (user != null) {
						log.warn("User {} owns site {} coop={}", user, site.getSiteShortName(), site.getOwnedBy().getId());
						aclService.createOrUpdatePermissions(site, user);
					} else {
						log.warn("Site {} owner acl_sid.id={} has no SysUser", site.getSiteShortName(), site.getOwnedBy().getId());
					}
				});
				return true;
			});
		} catch (Throwable e) {
			log.error("Could not set up ACL: {}", e.getMessage(), e);
		}

		try {
			asAdmin(() -> {
				// Inventory maintenance policies
				aclService.createOrUpdatePermissions(InventoryMaintenancePolicy.ACL_CLASS_IDENTITY);
				aclService.makePubliclyReadable(InventoryMaintenancePolicy.ACL_CLASS_IDENTITY, true);

				inventoryMaintPolicyRepository.findAll().forEach((inventoryMaintPolicy) -> {
//					SysUser user = userService.getUserForCooperator(inventoryMaintPolicy.getOwnedBy());
					// FIXME Should be introduced method getUserForAclSid() ???
					SysUser user = null;
					if (inventoryMaintPolicy.getOwnedBy() instanceof SysUser) {
						user = (SysUser) inventoryMaintPolicy.getOwnedBy();
					}
					if (user != null) {
						log.warn("User {} owns inventory maintenance policy {} acl_sid.id={}", user, inventoryMaintPolicy.getMaintenanceName(), inventoryMaintPolicy.getOwnedBy().getId());
						aclService.createOrUpdatePermissions(inventoryMaintPolicy, user);
					} else {
						log.warn("Inventory maintenance policy {} owner acl_sid.id={} of has no SysUser", inventoryMaintPolicy.getMaintenanceName(), inventoryMaintPolicy.getOwnedBy().getId());
						// Use admin as owner
						aclService.createOrUpdatePermissions(inventoryMaintPolicy);
					}
				});
				return true;
			});
		} catch (Throwable e) {
			log.error("Could not set up ACL: {}", e.getMessage(), e);
		}
	}

//	@Autowired
//	private SiteLocationRepository siteLocationRepository;
//	@Autowired
//	private SiteLocationService siteLocationService;

//	private void makeSiteLocations() {
//		LOG.warn("Generating sites from existing Inventory");
//		try {
//			if (siteLocationRepository.count() < 100) {
//				Long count = asAdmin(() -> {
//					return siteLocationService.generateSiteLocations();
//				});
//				LOG.warn("Added {} new SiteLocations from Inventory records", count);
//			}
//		} catch (Throwable e) {
//			LOG.warn("Could not migrate Inventory locations to SiteLocation: {}", e.getMessage(), e);
//		}
//	}

	private void createRepositoryFolder_AIA() {
		try {
			asAdmin(() -> {
				// ensure AIA folder has write permissions
				RepositoryFolder folder = repositoryService.ensureFolder(Paths.get("/AIA"));
				AclSid roleUser = aclService.ensureAuthoritySid("ROLE_USER");
				Permissions writePermissions = new Permissions().grantAll();
				aclService.setPermissions(folder, roleUser, writePermissions);
				// customAclService.makePubliclyReadable(folder, true);
				return folder;
			});
		} catch (Throwable e) {
			log.error("Could not ensure default folders: {}", e.getMessage(), e);
		}
	}

	private void ensureSystemInventoryPolicy() {
		InventoryMaintenancePolicy sysInvMaintPol = inventoryMaintPolicyRepository.findOne(QInventoryMaintenancePolicy.inventoryMaintenancePolicy.maintenanceName.eq("SYSTEM"))
			.orElse(null);
		if (sysInvMaintPol == null) {
			log.info("Inventory maintenance policy named SYSTEM is missing");

			try {
				asAdmin(() -> {
					InventoryMaintenancePolicy policy = new InventoryMaintenancePolicy();
					policy.setFormTypeCode(Inventory.SYSTEM_INVENTORY_FTC);
					policy.setManagementTypeCode(CommunityCodeValues.MANAGEMENT_TYPE_SYSTEM.value);
					policy.setMaintenanceName("SYSTEM");
					policy.setIsAutoDeducted("N");
					return inventoryMaintPolicyRepository.save(policy);
				});
				log.warn("Registered an Inventory maintenance policy named SYSTEM.");
			} catch (Throwable e) {
				log.error("Could not create SYSTEM inventory maintenance policy: {}", e.getMessage(), e);
			}
		}
	}

	private void ensureAuthoritySIDs() {
		try {
			TransactionHelper.executeInTransaction(false, () -> {
				return asAdmin(() -> {
					// Ensure system-wide user roles are defined
					for (UserRole systemRole : UserRole.values()) {
						log.info("Ensuring SID for role {}", systemRole.getAuthority());
						aclService.ensureAuthoritySid(systemRole.getAuthority());
					}
					// Ensure SysGroups are registered as SIDs
					sysGroupRepository.findAll().forEach(sysGroup -> {
						aclService.ensureAuthoritySid(sysGroup.getAuthority());
					});
					return true;
				});
			});
		} catch (Throwable e) {
			log.error("Could not ensure SID authorities: {}", e.getMessage(), e);
		}
	}

	/**
	 * Ensure 1 OAuth client.
	 */
	private void ensure1OAuthClient() {
		String frontendOrigin = null;
		boolean isLocalFrontend = false;
		try {
			var frontend = new URL(frontendUrl);
			if (frontend.getPort() != frontend.getDefaultPort() && frontend.getPort() != -1) {
				frontendOrigin = String.format("%s://%s:%s", frontend.getProtocol(), frontend.getHost(), frontend.getPort());
			} else {
				frontendOrigin = String.format("%s://%s", frontend.getProtocol(), frontend.getHost());
			}
			isLocalFrontend = StringUtils.equalsIgnoreCase("localhost", frontend.getHost()) 
				|| StringUtils.equalsIgnoreCase("127.0.0.1", frontend.getHost())
				// TODO IPv6
				;
		} catch (Exception e) {
			log.error("Invalid frontend URL {}", frontendUrl, e);
			return;
		}
		String baseUrlOrigin = baseUrl;
		try {
			var base = new URL(baseUrlOrigin);
			if (base.getPort() != base.getDefaultPort() && base.getPort() != -1) {
				baseUrlOrigin = String.format("%s://%s:%s", base.getProtocol(), base.getHost(), base.getPort());
			} else {
				baseUrlOrigin = String.format("%s://%s", base.getProtocol(), base.getHost());
			}
		} catch (Exception e) {
			log.error("Invalid base URL for origin {}", baseUrl, e);
		}

		OAuthClient existingDefaultClient = oauthClientRepository.findByClientId(defaultOAuthClientId);

		if (oauthClientRepository.count() == 0 || existingDefaultClient == null) {
			log.warn("Creating default OAuth client {}", defaultOAuthClientId);
			OAuthClient client = new OAuthClient();
			client.setActive(true);
			client.setClientId(defaultOAuthClientId);
			client.setClientSecret(passwordEncoder.encode(defaultOAuthClientSecret));
			client.getAuthorizedGrantTypes().add("authorization_code");
			client.getAuthorizedGrantTypes().add("client_credentials");
			client.getRoles().add(OAuthRole.CLIENT);
			client.getRoles().add(OAuthRole.TRUSTED_CLIENT);
			client.getScope().add("read");
			client.getScope().add("write");
			client.getScope().add("trust");
			client.setTitle("Default OAuth client");
			client.setDescription("This OAuth client was automatically created by the system.");

			client.getRegisteredRedirectUri().add(frontendUrl);
			client.getRegisteredRedirectUri().add((baseUrl.startsWith("http://localhost") ? baseUrl.replace("http://localhost", "http://127.0.0.1") : baseUrl) + "/login/oauth2/code/local"); // for testing
			client.getRegisteredRedirectUri().add((baseUrl.startsWith("http://localhost") ? baseUrl.replace("http://localhost", "http://127.0.0.1") : baseUrl) + "/swagger-ui/oauth2-redirect.html");

			client.getAllowedOrigins().add(baseUrlOrigin);
			client.getAllowedOrigins().add(frontendOrigin);
			client.getRegisteredRedirectUri().add(frontendUrl);

			// Add local development environment when on localhost
			if (isLocalFrontend) {
				client.getAllowedOrigins().add("http://127.0.0.1:3000");
				client.getRegisteredRedirectUri().add("http://127.0.0.1:3000");
				// The https version
				client.getAllowedOrigins().add("https://127.0.0.1:3433");
				client.getRegisteredRedirectUri().add("https://127.0.0.1:3443");
			}

			client = oauthClientRepository.save(client);

		} else {

			log.info("Updating default configuration for OAuth client {}", defaultOAuthClientId);
			OAuthClient client = existingDefaultClient;
			if (client != null) {
				client.getAllowedOrigins().add(baseUrlOrigin);
				client.getAllowedOrigins().add(frontendOrigin);
				client.getRegisteredRedirectUri().add(frontendOrigin);
				client.getRegisteredRedirectUri().add((baseUrl.startsWith("http://localhost") ? baseUrl.replace("http://localhost", "http://127.0.0.1") : baseUrl) + "/login/oauth2/code/local"); // for testing
				client.getRegisteredRedirectUri().add((baseUrl.startsWith("http://localhost") ? baseUrl.replace("http://localhost", "http://127.0.0.1") : baseUrl) + "/swagger-ui/oauth2-redirect.html");

				// Add local development environment when on localhost
				if (isLocalFrontend) {
					client.getAllowedOrigins().add("http://127.0.1:3000");
					client.getAllowedOrigins().add("https://127.0.1:3443");
					client.getRegisteredRedirectUri().add("http://127.0.0.1:3000");
					client.getRegisteredRedirectUri().add("https://127.0.0.1:3443");
				}

				client.getAuthorizedGrantTypes().remove("implicit");
				client.getAuthorizedGrantTypes().remove("password");
				client.getRoles().add(OAuthRole.TRUSTED_CLIENT);

				oauthService.updateClient(client.getId(), client.getVersion(), client);
				client = oauthClientRepository.findByClientId(defaultOAuthClientId);
				log.warn("Default OAuth client updated id={} redirect={} origins={}", client.getId(), client.getRedirect(), client.getOrigins());
			}
		}
	}

	/**
	 * Ensure KSU Fieldbook app client.
	 */
	private void addOrUpdateFieldBookClient() {
		try {
			var fieldbookClient = asAdmin(() -> {
				OAuthClient client = oAuthClientService.getClient("fieldbook");
				if (client == null) {
					log.info("Trying to create 'fieldbook' OAuth client");
					client = new OAuthClient();
					client.setActive(false); // Deactivated by default
					client.setClientId("fieldbook");
					client.setClientSecret(null);
					client.getAuthorizedGrantTypes().add("authorization_code");
					client.getAuthorizedGrantTypes().add("implicit");
					client.getRoles().add(OAuthRole.CLIENT);
					client.getScope().add("read");
					client.getScope().add("write");
					client.setTitle("FieldBook App");
					client.setDescription("OAuth client for KSU Field Book app.");

					var redirectUrls = client.getRegisteredRedirectUri(); // this is picked up by the Fieldbook app (manifest.xml)
					redirectUrls.add("fieldbook://app/auth");
					redirectUrls.add("https://phenoapps.org/field-book");
					redirectUrls.add("https://fieldbook.phenoapps.org");

					client = oAuthClientService.addClient(client);

				} else {
					// Update existing client with correct configuration for FieldBook
					client.setClientSecret(null); // No secret
					client.getAuthorizedGrantTypes().clear();
					client.getAuthorizedGrantTypes().add("authorization_code");
					var redirectUrls = client.getRegisteredRedirectUri(); // this is picked up by the Fieldbook app (manifest.xml)
					redirectUrls.add("fieldbook://app/auth");
					redirectUrls.add("https://phenoapps.org/field-book");
					redirectUrls.add("https://fieldbook.phenoapps.org");

					client = oAuthClientService.updateClient(client.getId(), client.getVersion(), client);
				}
				return client;
			});
			fieldbookClient = oAuthClientService.getClient("fieldbook");
			log.warn("Updated Fieldbook app client: {}", fieldbookClient);
		} catch (Exception e) {
			log.error("Could not update Fieldbook app client", e);
		}
	}

	private void upgradeDataviewsForGGCE() {
		try {
			asAdmin(() -> {
				sysTableFieldService.generateMapping("acl_sid", "id");
				sysTableFieldService.generateMapping("accession", "site_id");
				sysTableFieldService.generateMapping("accession", "exploration_id");
				sysTableFieldService.generateMapping("inventory", "site_id");
				sysTableFieldService.generateMapping("inventory", "barcode");
				sysTableFieldService.generateMapping("inventory", "production_location_geography_id");
				sysTableFieldService.generateMapping("inventory_maint_policy", "management_type_code");
				sysTableFieldService.generateMapping("sys_lang", "is_enabled");
				return true;
			});
		} catch (Exception e) {
			log.error("Could not: {}", e.getMessage(), e);
		}

		var ggceDataviews = List.of(
			// ACL
			"ggce_aclsid_lookup", "sys_user", "get_sys_user", "validate_login", "cooperator_lookup", "big_cooperator_lookup", 
			// model updates
			"get_accession", "get_inventory"
			// code values
			, "get_code_value", "get_code_value_lang"
			// don't forget to include new dataviews!
			, "get_inventory_maint_policy", "get_site"
			// sys_lang with is_enabled
			, "get_sys_language"
			// sys_matrix_input fixed for code_lang lookups
			, "sys_matrix_input"
			// New CT Attachment Wizard: attach_wizard_get_accession_inv_attach_filepath -- GGCE /AIA/...
			, "attach_wizard_get_accession_inv_attach_filepath"
			// Old CT Attachment Wizard -- GGCE /AIA/....
			, "inventory_attach_wizard_get_filepath"
		); // WARNING: If you change this, then update the changeLogId above to run it!

		for (var ggceDv : ggceDataviews) {
			try {
				asAdmin(() -> {
					try (InputStream ggceDefinition = ApplicationStartup.class.getResourceAsStream("/dataviews/" + ggceDv + ".dataviewxml")) {
						if (ggceDefinition == null) {
							log.warn("Custom Dataview definition for {} not found at /dataviews/{}/.dataviewxml", ggceDv, ggceDv);
							return null;
						}
	
						var existingDataview = sysDataviewService.load(ggceDv);
						if (existingDataview != null) {
							log.warn("Updating Dataview: {}", ggceDv);
							return sysDataviewService.updateDataviewFromXML(ggceDefinition, existingDataview);
						} else {
							log.warn("Importing Dataview: {}", ggceDv);
							return sysDataviewService.registerDataviewFromXML(ggceDefinition);
						}
					}
				});
			} catch (Exception e) {
				log.error("Could not register {} dataview: {}", ggceDv, e.getMessage(), e);
			}
		}
	}

	private void checkSysUserCooperator() {
		var userWithoutCooperator = jpaQueryFactory.selectFrom(QSysUser.sysUser)
			.where(QSysUser.sysUser.cooperator().isNull().and(QSysUser.sysUser.isEnabled.eq("Y"))).limit(100).fetch();

		for (var user : userWithoutCooperator) {
			transientMessageService.addAdminAlert(
				"sysUserNullCooperator-" + user.getId(),
				"\"" + user.getSid() + "\" is missing their Cooperator record."
			);
		}
		
		var repeatCooperatorIds = jpaQueryFactory.from(QSysUser.sysUser).select(QSysUser.sysUser.cooperator().id)
			.where(QSysUser.sysUser.isEnabled.eq("Y"))
			.groupBy(QSysUser.sysUser.cooperator().id)
			.having(QSysUser.sysUser.cooperator().id.count().gt(1))
			.fetch();
		
		if (!repeatCooperatorIds.isEmpty()) {
			transientMessageService.addAdminAlert(
				"sysUserSameCooperators",
				"Each active user must have a unique Cooperator record for the Curator Tool to function properly."
			);

			var userWithDupeCoop = jpaQueryFactory.selectFrom(QSysUser.sysUser)
				.where(QSysUser.sysUser.cooperator().id.in(repeatCooperatorIds)).limit(100).fetch();

			for (var user : userWithDupeCoop) {
				transientMessageService.addAdminAlert(
					"sysUserSameCooperators-" + user.getId(),
					"\"" + user.getSid() + "\" is sharing the Cooperator record with ID=" + user.getCooperator().getId() + "."
				);
			}
		}
		
		var usersWithoutSite = jpaQueryFactory.selectFrom(QSysUser.sysUser)
			.where(QSysUser.sysUser.isEnabled.eq("Y").and(QSysUser.sysUser.cooperator().site().isNull()))
			.limit(100).fetch();
		
		if (!usersWithoutSite.isEmpty()) {
			transientMessageService.addAdminAlert(
				"sysUserNullSite",
				"Some active users are using Cooperators that without a Site record. This will break the Curator Tool."
			);

			for (var user : usersWithoutSite) {
				transientMessageService.addAdminAlert(
					"sysUserNullSite-" + user.getId(),
					"\"" + user.getSid() + "\"'s Cooperator record does not specify the Site."
				);
			}
		}
	}

	private void installJasperTemplate_InventoryLabels() {
		addJasperTemplate("/reports/Inventory/InventoryLabels.jrxml", "InventoryLabels.jrxml", "Inventory QR labels - 3 columns", null, Inventory.class);
	}

	/**
	 * Install stock PDF templates
	 * @param resourcePath 
	 * @param targetFilename 
	 * @param title 
	 * @param description 
	 * @param targetClass
	 * @throws IOException 
	 * @throws FileNotFoundException 
	 * @throws InvalidRepositoryFileDataException 
	 * @throws InvalidRepositoryPathException 
	 */
	private void addJasperTemplate(String resourcePath, String targetFilename, String title, String description, Class<?> targetClass) {
		var reportMetadata = new RepositoryFile();
		reportMetadata.setCreated("support@ggce.genesys-pgr.org");
		reportMetadata.setLicense("CC-BY-NC");

		try (var reportInputStream = Files.newInputStream(Path.of(getClass().getResource(resourcePath).toURI()))) {
			reportMetadata.setTitle(title);
			reportMetadata.setDescription(StringUtils.defaultIfBlank(description, "Stock template"));

			var folderPath = Path.of(JasperReportService.REPORT_PATH, targetClass.getSimpleName());
			asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> {
				repositoryService.ensureFolder(folderPath);
				RepositoryFile file;
				try {
					file = repositoryService.getFile(folderPath, targetFilename);
					log.info("PDF template {} already exists in {}", targetFilename, folderPath);
				} catch (NoSuchRepositoryFileException e) {
					file = repositoryService.addFile(folderPath, targetFilename, null, reportInputStream, reportMetadata);
					log.warn("Added PDF template {} to {}", targetFilename, folderPath);
				}
				return file;
			}));
		} catch (Exception e) {
			log.warn("Could not add '': {} {}", resourcePath, e.getMessage());
		}
	}


	private void generateSeedInventoryExtra() {

		try {
			QInventoryViability qViability = new QInventoryViability("inventoryViability");
			QInventoryViability qViability2 = new QInventoryViability("inventoryViability2");
			QInventoryViability qViability3 = new QInventoryViability("inventoryViability3");

			asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> jpaQueryFactory.delete(QInventoryExtra.inventoryExtra).execute())); // Delete any existing extras.

			var lastViabilityList = jpaQueryFactory.selectFrom(qViability)
				.where(qViability.percentViable.isNotNull()
					.and(qViability.inventory().formTypeCode.ne(Inventory.SYSTEM_INVENTORY_FTC)) // No system inventories, please
					// where percentViable is not null and max(testedDate) and max(inventoryViability.id)
					.and(qViability.id.eq(
						jpaQueryFactory
							.from(qViability2)
							.select(qViability2.id.max())
							.where(
								qViability2.percentViable.isNotNull()
								.and(qViability2.testedDate.eq(
										jpaQueryFactory
											.from(qViability3)
											.select(qViability3.testedDate.max())
											.where(
												qViability3.percentViable.isNotNull()
												.and(qViability3.inventory().eq(qViability2.inventory()))
											)
									))
								.and(qViability2.inventory().eq(qViability.inventory()))
							)
					))
				)
				.fetch();

			List<InventoryExtra> extraForSave = new ArrayList<>();

			lastViabilityList.forEach(inventoryViability -> {
				SeedInventoryExtra seedInventoryExtra = new SeedInventoryExtra();
				seedInventoryExtra.setInventory(inventoryViability.getInventory());
				seedInventoryExtra.setLastViability(inventoryViability);
				extraForSave.add(seedInventoryExtra);
			});

			log.warn("Saving {} SeedInventoryExtra", extraForSave.size());
			asAdmin(() -> TransactionHelper.executeInTransaction(false, () -> inventoryExtraService.create(extraForSave)));
		} catch (Throwable e) {
			log.warn("Could not generate inventory extra with last viability");
			throw new RuntimeException(e);
		}
	}

	private void checkParentSystemInventory() {
		QInventory inventory1 = new QInventory("inventory1");
		QInventory inventory2 = new QInventory("inventory2");
		var inventoryWithSystemParent = jpaQueryFactory.select(inventory1.id).from(inventory1)
			.innerJoin(inventory1.parentInventory(), inventory2)
			.where(inventory2.formTypeCode.eq(Inventory.SYSTEM_INVENTORY_FTC))
			.orderBy(inventory1.id.asc())
			.limit(1).fetch();
		if (!inventoryWithSystemParent.isEmpty()) {
			transientMessageService.addAdminAlert(
				"inventoriesWithSystemParent", "Using SYSTEM inventory as a parent inventory is discouraged."
			);
		}
	}

	private void convertScheduledMethodsToNotificationSchedule() throws Exception {
		asAdmin(() -> {
			// KPINotifications.sendAllKPIs
			NotificationSchedule sendAllKPIsNotification = new NotificationSchedule();
			sendAllKPIsNotification.setGenerator(KPINotifications.class.getName());
			sendAllKPIsNotification.setMethod("sendAllKPIs");
			sendAllKPIsNotification.setCron("0 0 12 ? * MON-FRI");
			sendAllKPIsNotification.setIsActive("Y");
			sendAllKPIsNotification.setTimezone(ZoneId.systemDefault().getId());
			notificationScheduleService.createFast(sendAllKPIsNotification);
			return true;
		});
	}

	private void addActionNotificationProcessor() throws Exception {
		asAdmin(() -> {
			// KPINotifications.sendAllKPIs
			NotificationSchedule processorSchedule = new NotificationSchedule();
			processorSchedule.setGenerator(NotificationMessageEmailSender.class.getName());
			processorSchedule.setMethod("pendingMessageSender");
			processorSchedule.setCron("0 0/30 7-17 * * MON-FRI");
			processorSchedule.setIsActive("Y");
			processorSchedule.setTimezone(ZoneId.systemDefault().getId());
			processorSchedule.setTitle("Action notifications");
			processorSchedule = notificationScheduleService.createFast(processorSchedule);
			notificationScheduleService.subscribe(processorSchedule, List.of("GROUP_ADMINS"));
			return true;
		});
	}

	private void addPermissionOnViabilityAttachmentFolderForAllUsers() throws Exception {
		asAdmin(() -> {
			var folder = repositoryService.ensureFolder(Paths.get("/inventoryViability"));
			aclService.setPermissions(folder, aclService.getAuthoritySid(UserRole.USER.getAuthority()), new Permissions().grantAll());
			return true;
		});
	}
	
	private <T> T asAdmin(Callable<T> callable) throws Exception {
		UserDetails administrator = userService.loadUserByUsername("administrator");
		List<GrantedAuthority> authorities = Lists.newArrayList(new SimpleGrantedAuthority("ROLE_ADMINISTRATOR"));
		authorities.addAll(administrator.getAuthorities());
		Authentication authentication = new UsernamePasswordAuthenticationToken(administrator, null, authorities);
		return TransactionHelper.asUser(authentication, callable);
	}
}