From 54c846094f208809a23a1c0aa931d1e1407b17d2 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 18 Dec 2025 21:38:49 +0200 Subject: [PATCH 01/22] PATCH 8 --- .../api/managers/ExternalAccountManager.java | 397 ++++++++++++++---- .../PgExternalAccountPersistenceManager.java | 381 ++++++++++++++--- .../util/email/MailJetApiClientWrapper.java | 324 +++++++++++--- 3 files changed, 893 insertions(+), 209 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index b0af27a09f..01fdbfd943 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -30,6 +30,7 @@ import uk.ac.cam.cl.dtg.util.email.MailJetApiClientWrapper; import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; + public class ExternalAccountManager implements IExternalAccountManager { private static final Logger log = LoggerFactory.getLogger(ExternalAccountManager.class); @@ -58,111 +59,327 @@ public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IE */ @Override public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { + log.info("Starting Mailjet synchronization process"); + + List userRecordsToUpdate; try { - List userRecordsToUpdate = database.getRecentlyChangedRecords(); - - for (UserExternalAccountChanges userRecord : userRecordsToUpdate) { - - Long userId = userRecord.getUserId(); - log.debug(String.format("Processing user: %s", userId)); - try { - - String accountEmail = userRecord.getAccountEmail(); - boolean accountEmailDeliveryFailed = - EmailVerificationStatus.DELIVERY_FAILED.equals(userRecord.getEmailVerificationStatus()); - String mailjetId = userRecord.getProviderUserId(); - JSONObject mailjetDetails; - - if (null != mailjetId) { - // If there is a "mailjet_id", get the account from MailJet. - mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); - if (userRecord.isDeleted()) { - // Case: deleted from Isaac but still on MailJet: - // Expect: "deleted" but non-null "mailjet_id" - // Action: GDPR deletion, null out MailJet ID?, update provider_last_updated - log.debug("Case: deletion."); - deleteUserFromMailJet(mailjetId, userRecord); - } else if (accountEmailDeliveryFailed) { - // Case: DELIVERY_FAILED but already on MailJet - // Expect: DELIVERY_FAILED, but non-null "mailjet_id" - // Action: same as deletion? Or just remove from lists for now? - log.debug("Case: delivery failed."); - mailjetApi.updateUserSubscriptions(mailjetId, MailJetSubscriptionAction.REMOVE, - MailJetSubscriptionAction.REMOVE); - } else if (!accountEmail.toLowerCase().equals(mailjetDetails.getString("Email"))) { - // Case: account email change: - // Expect: non-null "mailjet_id", email in MailJet != email in database - // Action: delete old email, add new user for new email - log.debug("Case: account email change."); - mailjetApi.permanentlyDeleteAccountById(mailjetId); - mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); - updateUserOnMailJet(mailjetId, userRecord); - } else { - // Case: changed details/preferences: - // Expect: not deleted, not DELIVERY_FAILED - // Action: update details, update subscriptions, update provider_last_updated - log.debug("Case: generic preferences update."); - updateUserOnMailJet(mailjetId, userRecord); - } - } else { - if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { - // Case: new to Isaac, not on MailJet: - // Expect: null "mailjet_id", not DELIVERY_FAILED, not deleted - // Action: create MailJet ID, update details, update subscriptions, update provider_last_updated - // This will upload even users who are not subscribed to emails. - log.debug("Case: new to Isaac/not yet on MailJet"); - mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); - updateUserOnMailJet(mailjetId, userRecord); - } else { - // Case: not on MailJet, do not upload to Mailjet: - // Expect: either invalid email, deleted, or not subscribed at all so don't upload. - log.debug("Case: invalid/incorrect/already-unsubscribed user to skip."); - database.updateExternalAccount(userId, null); - } - } - // Iff action done successfully, update the provider_last_updated time: - log.debug("Update provider_last_updated."); - database.updateProviderLastUpdated(userId); - - } catch (SegueDatabaseException e) { - log.error(String.format("Error storing record of MailJet update to user (%s)!", userId)); - } catch (MailjetClientCommunicationException e) { - log.error("Failed to talk to MailJet!"); - throw new ExternalAccountSynchronisationException("Failed to successfully connect to MailJet!"); - } catch (MailjetRateLimitException e) { - log.warn("MailJet rate limiting!"); - throw new ExternalAccountSynchronisationException("MailJet API rate limits exceeded!"); - } catch (MailjetException e) { - log.error(e.getMessage()); - throw new ExternalAccountSynchronisationException(e.getMessage()); - } - } + userRecordsToUpdate = database.getRecentlyChangedRecords(); + log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); } catch (SegueDatabaseException e) { log.error("Database error whilst collecting users whose details have changed!", e); + throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization: " + e); + } + + if (userRecordsToUpdate.isEmpty()) { + log.info("No users to synchronize. Exiting."); + return; + } + + SyncMetrics metrics = new SyncMetrics(); + + for (UserExternalAccountChanges userRecord : userRecordsToUpdate) { + Long userId = userRecord.getUserId(); + + try { + log.debug("Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); + processUserSync(userRecord, metrics); + metrics.incrementSuccess(); + log.debug("Successfully processed user ID: {}", userId); + + } catch (SegueDatabaseException e) { + metrics.incrementDatabaseError(); + log.error("Database error storing Mailjet update for user ID: {}. Error: {}", + userId, e.getMessage(), e); + // Continue processing other users - database errors shouldn't stop the entire sync + + } catch (MailjetClientCommunicationException e) { + metrics.incrementCommunicationError(); + log.error("Failed to communicate with Mailjet while processing user ID: {}. Error: {}", + userId, e.getMessage(), e); + throw new ExternalAccountSynchronisationException( + "Failed to successfully connect to Mailjet" + e); + + } catch (MailjetRateLimitException e) { + metrics.incrementRateLimitError(); + log.warn("Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit.", + userId, metrics.getSuccessCount()); + throw new ExternalAccountSynchronisationException( + "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); + + } catch (MailjetException e) { + metrics.incrementMailjetError(); + log.error("Mailjet API error while processing user ID: {}. Error: {}", + userId, e.getMessage(), e); + throw new ExternalAccountSynchronisationException( + "Mailjet API error: " + e.getMessage() + e); + + } catch (Exception e) { + metrics.incrementUnexpectedError(); + log.error("Unexpected error processing user ID: {}. Error: {}", + userId, e.getMessage(), e); + // Don't throw - log and continue to avoid blocking all syncs + } } + + logSyncSummary(metrics, userRecordsToUpdate.size()); } + /** + * Process synchronization for a single user. + */ + private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + String accountEmail = userRecord.getAccountEmail(); + + // Validate required fields + if (accountEmail == null || accountEmail.trim().isEmpty()) { + log.warn("User ID {} has null or empty email address. Skipping.", userId); + metrics.incrementSkipped(); + return; + } + + boolean accountEmailDeliveryFailed = + EmailVerificationStatus.DELIVERY_FAILED.equals(userRecord.getEmailVerificationStatus()); + String mailjetId = userRecord.getProviderUserId(); + + if (mailjetId != null && !mailjetId.trim().isEmpty()) { + handleExistingMailjetUser(mailjetId, userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + } else { + handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + } + + // Update the provider_last_updated timestamp on success + database.updateProviderLastUpdated(userId); + log.debug("Updated provider_last_updated timestamp for user ID: {}", userId); + } + + /** + * Handle synchronization for users that already exist in Mailjet. + */ + private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChanges userRecord, + String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + + // Fetch current Mailjet details + JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); + + if (mailjetDetails == null) { + log.warn("User ID {} has Mailjet ID {} but account not found in Mailjet. Treating as new user.", + userId, mailjetId); + // Mailjet account doesn't exist - clear the ID and treat as new + database.updateExternalAccount(userId, null); + handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + return; + } + + if (userRecord.isDeleted()) { + log.info("User ID {} is deleted. Removing from Mailjet.", userId); + deleteUserFromMailJet(mailjetId, userRecord); + metrics.incrementDeleted(); + + } else if (accountEmailDeliveryFailed) { + log.info("User ID {} has delivery failed status. Unsubscribing from all lists.", userId); + mailjetApi.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.REMOVE, + MailJetSubscriptionAction.REMOVE); + metrics.incrementUnsubscribed(); + + } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { + log.info("User ID {} changed email from {} to {}. Recreating Mailjet account.", + userId, maskEmail(mailjetDetails.getString("Email")), maskEmail(accountEmail)); + mailjetApi.permanentlyDeleteAccountById(mailjetId); + String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + + if (newMailjetId == null) { + throw new MailjetException("Failed to create new Mailjet account after email change for user: " + userId); + } + + updateUserOnMailJet(newMailjetId, userRecord); + metrics.incrementEmailChanged(); + + } else { + log.debug("User ID {} has updated details/preferences. Updating Mailjet.", userId); + updateUserOnMailJet(mailjetId, userRecord); + metrics.incrementUpdated(); + } + } + + /** + * Handle synchronization for users that don't exist in Mailjet yet. + */ + private void handleNewMailjetUser(UserExternalAccountChanges userRecord, + String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + + if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { + log.info("Creating new Mailjet account for user ID {} with email {}", + userId, maskEmail(accountEmail)); + + String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + + if (mailjetId == null) { + log.error("Failed to create Mailjet account for user ID {}. Mailjet returned null ID.", userId); + throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); + } + + updateUserOnMailJet(mailjetId, userRecord); + metrics.incrementCreated(); + + } else { + log.debug("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", + userId, userRecord.isDeleted(), accountEmailDeliveryFailed); + database.updateExternalAccount(userId, null); + metrics.incrementSkipped(); + } + } + + /** + * Update user details and subscriptions in Mailjet. + */ private void updateUserOnMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) - throws SegueDatabaseException, MailjetException { + throws SegueDatabaseException, MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + Long userId = userRecord.getUserId(); - mailjetApi.updateUserProperties(mailjetId, userRecord.getGivenName(), userRecord.getRole().toString(), - userRecord.getEmailVerificationStatus().toString(), userRecord.getStage()); - - MailJetSubscriptionAction newsStatus = (userRecord.allowsNewsEmails() != null - && userRecord.allowsNewsEmails()) ? MailJetSubscriptionAction.FORCE_SUBSCRIBE : - MailJetSubscriptionAction.UNSUBSCRIBE; - MailJetSubscriptionAction eventsStatus = (userRecord.allowsEventsEmails() != null - && userRecord.allowsEventsEmails()) ? MailJetSubscriptionAction.FORCE_SUBSCRIBE : - MailJetSubscriptionAction.UNSUBSCRIBE; + + // Update user properties + String stage = userRecord.getStage() != null ? userRecord.getStage() : "unknown"; + mailjetApi.updateUserProperties( + mailjetId, + userRecord.getGivenName(), + userRecord.getRole().toString(), + userRecord.getEmailVerificationStatus().toString(), + stage + ); + + // Update subscriptions + MailJetSubscriptionAction newsStatus = Boolean.TRUE.equals(userRecord.allowsNewsEmails()) + ? MailJetSubscriptionAction.FORCE_SUBSCRIBE + : MailJetSubscriptionAction.UNSUBSCRIBE; + + MailJetSubscriptionAction eventsStatus = Boolean.TRUE.equals(userRecord.allowsEventsEmails()) + ? MailJetSubscriptionAction.FORCE_SUBSCRIBE + : MailJetSubscriptionAction.UNSUBSCRIBE; + mailjetApi.updateUserSubscriptions(mailjetId, newsStatus, eventsStatus); + // Store the Mailjet ID in the database database.updateExternalAccount(userId, mailjetId); + + log.debug("Updated Mailjet account {} for user ID {} (news={}, events={})", + mailjetId, userId, newsStatus, eventsStatus); } + /** + * Delete user from Mailjet (GDPR compliance). + */ private void deleteUserFromMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) - throws SegueDatabaseException, MailjetException { + throws SegueDatabaseException, MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + log.warn("Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); + return; + } + Long userId = userRecord.getUserId(); mailjetApi.permanentlyDeleteAccountById(mailjetId); database.updateExternalAccount(userId, null); + + log.info("Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); + } + + /** + * Mask email address for logging (show only first 3 chars and domain). + */ + private String maskEmail(String email) { + if (email == null || email.isEmpty()) { + return "[empty]"; + } + + int atIndex = email.indexOf('@'); + if (atIndex <= 0) { + return email.substring(0, Math.min(3, email.length())) + "***"; + } + + String localPart = email.substring(0, atIndex); + String domain = email.substring(atIndex); + String masked = localPart.substring(0, Math.min(3, localPart.length())) + "***"; + + return masked + domain; + } + + /** + * Log summary of synchronization results. + */ + private void logSyncSummary(SyncMetrics metrics, int totalUsers) { + log.info("=== Mailjet Synchronization Complete ==="); + log.info("Total users to process: {}", totalUsers); + log.info("Successfully processed: {}", metrics.getSuccessCount()); + log.info(" - Created: {}", metrics.getCreatedCount()); + log.info(" - Updated: {}", metrics.getUpdatedCount()); + log.info(" - Deleted: {}", metrics.getDeletedCount()); + log.info(" - Email changed: {}", metrics.getEmailChangedCount()); + log.info(" - Unsubscribed: {}", metrics.getUnsubscribedCount()); + log.info(" - Skipped: {}", metrics.getSkippedCount()); + log.info("Errors:"); + log.info(" - Database errors: {}", metrics.getDatabaseErrorCount()); + log.info(" - Communication errors: {}", metrics.getCommunicationErrorCount()); + log.info(" - Rate limit errors: {}", metrics.getRateLimitErrorCount()); + log.info(" - Mailjet API errors: {}", metrics.getMailjetErrorCount()); + log.info(" - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); + log.info("========================================"); + } + + /** + * Inner class to track synchronization metrics. + */ + private static class SyncMetrics { + private int successCount = 0; + private int createdCount = 0; + private int updatedCount = 0; + private int deletedCount = 0; + private int emailChangedCount = 0; + private int unsubscribedCount = 0; + private int skippedCount = 0; + private int databaseErrorCount = 0; + private int communicationErrorCount = 0; + private int rateLimitErrorCount = 0; + private int mailjetErrorCount = 0; + private int unexpectedErrorCount = 0; + + void incrementSuccess() { successCount++; } + void incrementCreated() { createdCount++; } + void incrementUpdated() { updatedCount++; } + void incrementDeleted() { deletedCount++; } + void incrementEmailChanged() { emailChangedCount++; } + void incrementUnsubscribed() { unsubscribedCount++; } + void incrementSkipped() { skippedCount++; } + void incrementDatabaseError() { databaseErrorCount++; } + void incrementCommunicationError() { communicationErrorCount++; } + void incrementRateLimitError() { rateLimitErrorCount++; } + void incrementMailjetError() { mailjetErrorCount++; } + void incrementUnexpectedError() { unexpectedErrorCount++; } + + int getSuccessCount() { return successCount; } + int getCreatedCount() { return createdCount; } + int getUpdatedCount() { return updatedCount; } + int getDeletedCount() { return deletedCount; } + int getEmailChangedCount() { return emailChangedCount; } + int getUnsubscribedCount() { return unsubscribedCount; } + int getSkippedCount() { return skippedCount; } + int getDatabaseErrorCount() { return databaseErrorCount; } + int getCommunicationErrorCount() { return communicationErrorCount; } + int getRateLimitErrorCount() { return rateLimitErrorCount; } + int getMailjetErrorCount() { return mailjetErrorCount; } + int getUnexpectedErrorCount() { return unexpectedErrorCount; } } -} +} \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index 6fb0992236..44ed78e7d0 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -1,6 +1,5 @@ package uk.ac.cam.cl.dtg.segue.dao.users; -import com.google.api.client.util.Lists; import com.google.inject.Inject; import java.sql.Connection; import java.sql.PreparedStatement; @@ -8,8 +7,10 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -18,7 +19,6 @@ import uk.ac.cam.cl.dtg.isaac.dos.users.UserExternalAccountChanges; import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; - /** * This class is responsible for managing and persisting user data. */ @@ -39,95 +39,366 @@ public PgExternalAccountPersistenceManager(final PostgresSqlDb database) { @Override public List getRecentlyChangedRecords() throws SegueDatabaseException { - String query = "SELECT id, provider_user_identifier, email, role, given_name, deleted, email_verification_status, " - + " news_prefs.preference_value AS news_emails, events_prefs.preference_value AS events_emails " - + "FROM users " - + " LEFT OUTER JOIN user_preferences AS news_prefs ON users.id = news_prefs.user_id " - + "AND news_prefs.preference_type='EMAIL_PREFERENCE' " - + "AND news_prefs.preference_name='NEWS_AND_UPDATES' " - + " LEFT OUTER JOIN user_preferences AS events_prefs ON users.id = events_prefs.user_id " - + "AND events_prefs.preference_type='EMAIL_PREFERENCE' " - + "AND events_prefs.preference_name='EVENTS' " - + " LEFT OUTER JOIN external_accounts ON users.id=external_accounts.user_id AND provider_name='MailJet' " - + "WHERE (users.last_updated >= provider_last_updated OR news_prefs.last_updated >= provider_last_updated " - + " OR events_prefs.last_updated >= provider_last_updated OR provider_last_updated IS NULL)"; + // Note: registered_contexts is JSONB[] (array of JSONB objects) in PostgreSQL + // We need to cast it to TEXT to parse it properly in Java + String query = "SELECT users.id, " + + " external_accounts.provider_user_identifier, " + + " users.email, " + + " users.role, " + + " users.given_name, " + + " users.deleted, " + + " users.email_verification_status, " + + " CAST(users.registered_contexts AS TEXT) AS registered_contexts, " // CAST JSONB[] to TEXT + + " news_prefs.preference_value AS news_emails, " + + " events_prefs.preference_value AS events_emails, " + + " external_accounts.provider_last_updated " + + "FROM users " + + " LEFT OUTER JOIN user_preferences AS news_prefs " + + " ON users.id = news_prefs.user_id " + + " AND news_prefs.preference_type = 'EMAIL_PREFERENCE' " + + " AND news_prefs.preference_name = 'NEWS_AND_UPDATES' " + + " LEFT OUTER JOIN user_preferences AS events_prefs " + + " ON users.id = events_prefs.user_id " + + " AND events_prefs.preference_type = 'EMAIL_PREFERENCE' " + + " AND events_prefs.preference_name = 'EVENTS' " + + " LEFT OUTER JOIN external_accounts " + + " ON users.id = external_accounts.user_id " + + " AND external_accounts.provider_name = 'MailJet' " + + "WHERE (users.last_updated >= external_accounts.provider_last_updated " + + " OR news_prefs.last_updated >= external_accounts.provider_last_updated " + + " OR events_prefs.last_updated >= external_accounts.provider_last_updated " + + " OR external_accounts.provider_last_updated IS NULL) " + + "ORDER BY users.id"; + try (Connection conn = database.getDatabaseConnection(); - PreparedStatement pst = conn.prepareStatement(query); - ResultSet results = pst.executeQuery() + PreparedStatement pst = conn.prepareStatement(query) ) { - List listOfResults = Lists.newArrayList(); + log.debug("Executing query to fetch recently changed user records"); - while (results.next()) { - listOfResults.add(buildUserExternalAccountChanges(results)); - } + try (ResultSet results = pst.executeQuery()) { + List listOfResults = new ArrayList<>(); + + while (results.next()) { + try { + UserExternalAccountChanges userChange = buildUserExternalAccountChanges(results); + listOfResults.add(userChange); + } catch (SQLException | JSONException e) { + // Log but continue processing other users + long userId = results.getLong("id"); + log.error("Error building UserExternalAccountChanges for user ID: {}. Error: {}", + userId, e.getMessage(), e); + } + } - return listOfResults; + log.info("Retrieved {} user records requiring synchronization", listOfResults.size()); + return listOfResults; + } } catch (SQLException e) { - throw new SegueDatabaseException("Postgres exception", e); + log.error("Database error while fetching recently changed records", e); + throw new SegueDatabaseException("Failed to retrieve recently changed user records", e); } } @Override public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseException { - String query = "UPDATE external_accounts SET provider_last_updated=? WHERE user_id=? AND provider_name='MailJet';"; + if (userId == null) { + throw new IllegalArgumentException("User ID cannot be null"); + } + + String query = "UPDATE external_accounts " + + "SET provider_last_updated = ? " + + "WHERE user_id = ? " + + "AND provider_name = 'MailJet'"; + try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { pst.setTimestamp(1, Timestamp.from(Instant.now())); pst.setLong(2, userId); - pst.executeUpdate(); + int rowsUpdated = pst.executeUpdate(); + + if (rowsUpdated == 0) { + log.warn("No rows updated when setting provider_last_updated for user ID: {}. " + + "User may not have an external_accounts record yet.", userId); + } else { + log.debug("Updated provider_last_updated for user ID: {}", userId); + } + } catch (SQLException e) { - throw new SegueDatabaseException("Postgres exception on update ", e); + log.error("Database error updating provider_last_updated for user ID: {}", userId, e); + throw new SegueDatabaseException("Failed to update provider_last_updated for user: " + userId, e); } } @Override public void updateExternalAccount(final Long userId, final String providerUserIdentifier) - throws SegueDatabaseException { - // Upsert the value in, using Postgres 9.5 syntax 'ON CONFLICT DO UPDATE ...' - String query = - "INSERT INTO external_accounts(user_id, provider_name, provider_user_identifier) VALUES (?, 'MailJet', ?)" - + " ON CONFLICT (user_id, provider_name) DO UPDATE SET" - + " provider_user_identifier=excluded.provider_user_identifier"; + throws SegueDatabaseException { + + if (userId == null) { + throw new IllegalArgumentException("User ID cannot be null"); + } + + // Upsert the value in, using Postgres 9.5+ syntax 'ON CONFLICT DO UPDATE ...' + String query = "INSERT INTO external_accounts (user_id, provider_name, provider_user_identifier) " + + "VALUES (?, 'MailJet', ?) " + + "ON CONFLICT (user_id, provider_name) " + + "DO UPDATE SET provider_user_identifier = excluded.provider_user_identifier"; + try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { pst.setLong(1, userId); pst.setString(2, providerUserIdentifier); - pst.executeUpdate(); + int rowsAffected = pst.executeUpdate(); + + if (rowsAffected > 0) { + log.debug("Upserted external_account for user ID: {} with Mailjet ID: {}", + userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); + } else { + log.warn("Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); + } + } catch (SQLException e) { - throw new SegueDatabaseException("Postgres exception on upsert ", e); + log.error("Database error upserting external_account for user ID: {} with Mailjet ID: {}", + userId, providerUserIdentifier, e); + throw new SegueDatabaseException( + "Failed to upsert external_account for user: " + userId, e); } } - private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultSet results) throws SQLException { - String registeredContextsJson = results.getString("registered_contexts"); + /** + * Build UserExternalAccountChanges object from database result set. + * Extracts stage information from registered_contexts JSONB[] field. + * Parses boolean preference values with proper null handling. + */ + private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultSet results) + throws SQLException { - // Parse the JSON string if it's not null - JSONObject registeredContexts = null; - if (registeredContextsJson != null && !registeredContextsJson.isEmpty()) { - registeredContexts = new JSONObject(registeredContextsJson); - } + Long userId = results.getLong("id"); + + // Parse registered_contexts (JSONB[] -> String -> stage) + String registeredContextsJson = results.getString("registered_contexts"); + String stage = extractStageFromRegisteredContexts(userId, registeredContextsJson); - // Extract stage from the JSON object, or use a default value - String stage = (registeredContexts != null && registeredContexts.has("stage")) - ? registeredContexts.getString("stage") - : "unknown"; + // Parse boolean preferences with null handling + Boolean newsEmails = parseBooleanPreference(userId, "NEWS_AND_UPDATES", results, "news_emails"); + Boolean eventsEmails = parseBooleanPreference(userId, "EVENTS", results, "events_emails"); return new UserExternalAccountChanges( - results.getLong("id"), - results.getString("provider_user_identifier"), - results.getString("email"), - Role.valueOf(results.getString("role")), - results.getString("given_name"), - results.getBoolean("deleted"), - EmailVerificationStatus.valueOf(results.getString("email_verification_status")), - results.getBoolean("news_emails"), - results.getBoolean("events_emails"), - stage // Pass the extracted stage as a string + userId, + results.getString("provider_user_identifier"), + results.getString("email"), + Role.valueOf(results.getString("role")), + results.getString("given_name"), + results.getBoolean("deleted"), + EmailVerificationStatus.valueOf(results.getString("email_verification_status")), + newsEmails, + eventsEmails, + stage ); } -} + + /** + * Parse boolean preference value from ResultSet with proper null handling. + * PostgreSQL boolean columns can be NULL, which JDBC returns as false by default. + * We need to check wasNull() to distinguish between false and NULL. + * + * @param userId User ID for logging + * @param preferenceName Name of preference for logging + * @param results ResultSet containing the data + * @param columnName Column name in ResultSet + * @return Boolean value (true/false/null) + */ + private Boolean parseBooleanPreference(Long userId, String preferenceName, + ResultSet results, String columnName) throws SQLException { + boolean value = results.getBoolean(columnName); + boolean wasNull = results.wasNull(); + + if (wasNull) { + // User has no preference set - treat as null (not subscribed) + log.debug("User ID {} has NULL preference for {}. Treating as not subscribed.", + userId, preferenceName); + return null; + } + + log.debug("User ID {} has preference {} = {}", userId, preferenceName, value); + return value; + } + + /** + * Extract stage information from registered_contexts JSONB[] field. + * + * PostgreSQL stores this as JSONB[] (array of JSONB objects), which we cast to TEXT in the query. + * The TEXT representation looks like: + * - Single object: '{"stage": "gcse"}' + * - Array of objects: '[{"stage": "gcse"}, {"exam_board": "AQA"}]' + * - Empty: NULL, '{}', or '[]' + * + * @param userId User ID for logging + * @param registeredContextsJson JSONB[] field cast to TEXT + * @return stage string: "GCSE", "A Level", "GCSE and A Level", or "unknown" + */ + private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { + if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { + log.debug("User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); + return "unknown"; + } + + String trimmed = registeredContextsJson.trim(); + + // Check for empty JSON object or array + if ("{}".equals(trimmed) || "[]".equals(trimmed)) { + log.debug("User ID {} has empty registered_contexts '{}'. Stage: unknown", userId, trimmed); + return "unknown"; + } + + try { + // Try to parse as JSONArray first (JSONB[] is typically an array) + if (trimmed.startsWith("[")) { + return extractStageFromJsonArray(userId, trimmed); + } else if (trimmed.startsWith("{")) { + // Single JSON object (less common but possible) + return extractStageFromJsonObject(userId, trimmed); + } else { + log.warn("User ID {} has unexpected registered_contexts format (not JSON): '{}'. Stage: unknown", + userId, truncateForLog(registeredContextsJson)); + return "unknown"; + } + + } catch (JSONException e) { + log.warn("User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", + userId, truncateForLog(registeredContextsJson), e.getMessage()); + return "unknown"; + } + } + + /** + * Extract stage from JSON array format: [{"stage": "gcse"}, {...}] + */ + private String extractStageFromJsonArray(Long userId, String jsonArrayString) throws JSONException { + JSONArray array = new JSONArray(jsonArrayString); + + if (array.isEmpty()) { + log.debug("User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); + return "unknown"; + } + + // Check each object in the array for 'stage' key + for (int i = 0; i < array.length(); i++) { + Object item = array.get(i); + if (item instanceof JSONObject) { + JSONObject obj = (JSONObject) item; + if (obj.has("stage")) { + String stage = obj.getString("stage"); + String normalized = normalizeStage(stage); + log.debug("User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + userId, stage, i, normalized); + return normalized; + } + } + } + + // No 'stage' key found, use fallback pattern matching + String fallbackStage = fallbackStageDetection(userId, jsonArrayString); + if (!"unknown".equals(fallbackStage)) { + log.info("User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + } else { + log.warn("User ID {} has registered_contexts array but no 'stage' key found: {}. Stage: unknown", + userId, truncateForLog(jsonArrayString)); + } + return fallbackStage; + } + + /** + * Extract stage from JSON object format: {"stage": "gcse", ...} + */ + private String extractStageFromJsonObject(Long userId, String jsonObjectString) throws JSONException { + JSONObject obj = new JSONObject(jsonObjectString); + + if (obj.has("stage")) { + String stage = obj.getString("stage"); + String normalized = normalizeStage(stage); + log.debug("User ID {} has stage '{}' in registered_contexts. Normalized: {}", + userId, stage, normalized); + return normalized; + } + + // No 'stage' key found, use fallback pattern matching + String fallbackStage = fallbackStageDetection(userId, jsonObjectString); + if (!"unknown".equals(fallbackStage)) { + log.info("User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + } else { + log.warn("User ID {} has registered_contexts object but no 'stage' key: {}. Stage: unknown", + userId, truncateForLog(jsonObjectString)); + } + return fallbackStage; + } + + /** + * Fallback stage detection by pattern matching in the JSON string. + * Used when no explicit 'stage' key is found. + */ + private String fallbackStageDetection(Long userId, String jsonString) { + String lower = jsonString.toLowerCase(); + boolean hasGcse = lower.contains("gcse"); + boolean hasALevel = lower.contains("a_level") || lower.contains("alevel") || lower.contains("a level"); + + if (hasGcse && hasALevel) { + return "GCSE and A Level"; + } else if (hasGcse) { + return "GCSE"; + } else if (hasALevel) { + return "A Level"; + } + + return "unknown"; + } + + /** + * Normalize stage values to consistent format for Mailjet. + */ + private String normalizeStage(String stage) { + if (stage == null || stage.trim().isEmpty()) { + return "unknown"; + } + + String normalized = stage.trim().toLowerCase(); + + switch (normalized) { + case "gcse": + return "GCSE"; + case "a_level": + case "a level": + case "alevel": + case "a-level": + return "A Level"; + case "gcse_and_a_level": + case "gcse and a level": + case "both": + case "gcse,a_level": + case "gcse, a level": + return "GCSE and A Level"; + default: + // Warn about unexpected stage values + log.warn("Unexpected stage value '{}' encountered. Returning 'unknown'. " + + "Expected values: gcse, a_level, gcse_and_a_level, both", stage); + return "unknown"; + } + } + + /** + * Truncate long strings for logging to avoid cluttering logs. + */ + private String truncateForLog(String str) { + if (str == null) { + return "null"; + } + if (str.length() <= 100) { + return str; + } + return str.substring(0, 97) + "..."; + } +} \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index b1e5b160ed..e4918fd7ed 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -16,13 +16,12 @@ package uk.ac.cam.cl.dtg.util.email; -import static java.util.Objects.requireNonNull; - import com.google.inject.Inject; import com.mailjet.client.ClientOptions; import com.mailjet.client.MailjetClient; import com.mailjet.client.MailjetRequest; import com.mailjet.client.MailjetResponse; +import com.mailjet.client.errors.MailjetClientCommunicationException; import com.mailjet.client.errors.MailjetClientRequestException; import com.mailjet.client.errors.MailjetException; import com.mailjet.client.resource.Contact; @@ -57,35 +56,74 @@ public class MailJetApiClientWrapper { public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, final String mailjetNewsListId, final String mailjetEventsListId, final String mailjetLegalListId) { + + if (mailjetApiKey == null || mailjetApiSecret == null) { + throw new IllegalArgumentException("Mailjet API credentials cannot be null"); + } + ClientOptions options = ClientOptions.builder() - .apiKey(mailjetApiKey) - .apiSecretKey(mailjetApiSecret) - .build(); + .apiKey(mailjetApiKey) + .apiSecretKey(mailjetApiSecret) + .build(); this.mailjetClient = new MailjetClient(options); this.newsListId = mailjetNewsListId; this.eventsListId = mailjetEventsListId; this.legalListId = mailjetLegalListId; + + log.info("MailJet API wrapper initialized with list IDs - News: {}, Events: {}, Legal: {}", + newsListId, eventsListId, legalListId); } /** * Get user details for an existing MailJet account. * * @param mailjetIdOrEmail - email address or MailJet user ID - * @return JSONObject of the MailJet user + * @return JSONObject of the MailJet user, or null if not found * @throws MailjetException - if underlying MailjetClient throws an exception */ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { - if (null == mailjetIdOrEmail) { + if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { + log.debug("Attempted to get account with null/empty identifier"); return null; } - MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); - MailjetResponse response = mailjetClient.get(request); - JSONArray responseData = response.getData(); - if (response.getTotal() == 1) { - return responseData.getJSONObject(0); + + try { + log.debug("Fetching Mailjet account: {}", mailjetIdOrEmail); + MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); + MailjetResponse response = mailjetClient.get(request); + + if (response.getStatus() == 404) { + log.debug("Mailjet account not found: {}", mailjetIdOrEmail); + return null; + } + + if (response.getStatus() != 200) { + log.warn("Unexpected Mailjet response status {} when fetching account: {}", + response.getStatus(), mailjetIdOrEmail); + throw new MailjetException("Unexpected response status: " + response.getStatus()); + } + + JSONArray responseData = response.getData(); + if (response.getTotal() == 1 && !responseData.isEmpty()) { + log.debug("Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); + return responseData.getJSONObject(0); + } + + log.debug("Mailjet account not found (total={}): {}", response.getTotal(), mailjetIdOrEmail); + return null; + + } catch (MailjetException e) { + // Check if it's a timeout/communication issue + if (e.getMessage() != null && + (e.getMessage().toLowerCase().contains("timeout") || + e.getMessage().toLowerCase().contains("connection"))) { + log.error("Communication error fetching Mailjet account: {}", mailjetIdOrEmail, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + log.error("Error fetching Mailjet account: {}", mailjetIdOrEmail, e); + throw e; } - return null; } /** @@ -95,43 +133,119 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma * @throws MailjetException - if underlying MailjetClient throws an exception */ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetException { - requireNonNull(mailjetId); - MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); - mailjetClient.delete(request); + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + + try { + log.info("Deleting Mailjet account: {}", mailjetId); + MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); + MailjetResponse response = mailjetClient.delete(request); + + if (response.getStatus() == 204 || response.getStatus() == 200) { + log.info("Successfully deleted Mailjet account: {}", mailjetId); + } else if (response.getStatus() == 404) { + log.warn("Attempted to delete non-existent Mailjet account: {}", mailjetId); + // Don't throw - account is already gone + } else { + log.error("Unexpected response status {} when deleting Mailjet account: {}", + response.getStatus(), mailjetId); + throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); + } + + } catch (MailjetException e) { + // Check if it's a timeout/communication issue + if (e.getMessage() != null && + (e.getMessage().toLowerCase().contains("timeout") || + e.getMessage().toLowerCase().contains("connection"))) { + log.error("Communication error deleting Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + log.error("Error deleting Mailjet account: {}", mailjetId, e); + throw e; + } } /** - * Add a new user to MailJet + * Add a new user to MailJet. *
* If the user already exists, find by email as a fallback to ensure idempotence and better error recovery. * * @param email - email address - * @return the MailJet user ID + * @return the MailJet user ID, or null on failure * @throws MailjetException - if underlying MailjetClient throws an exception */ public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { - if (null == email) { + if (email == null || email.trim().isEmpty()) { + log.warn("Attempted to create Mailjet account with null/empty email"); return null; } + + String normalizedEmail = email.trim().toLowerCase(); + try { - MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, email); + log.debug("Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); + + MailjetRequest request = new MailjetRequest(Contact.resource) + .property(Contact.EMAIL, normalizedEmail); MailjetResponse response = mailjetClient.post(request); - // Get MailJet ID out: - JSONObject responseData = response.getData().getJSONObject(0); - return Integer.toString(responseData.getInt("ID")); + + if (response.getStatus() == 201 || response.getStatus() == 200) { + JSONObject responseData = response.getData().getJSONObject(0); + String mailjetId = Integer.toString(responseData.getInt("ID")); + log.info("Successfully created Mailjet account {} for email: {}", + mailjetId, maskEmail(normalizedEmail)); + return mailjetId; + } + + log.error("Unexpected response status {} when creating Mailjet account for: {}", + response.getStatus(), maskEmail(normalizedEmail)); + throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + } catch (MailjetClientRequestException e) { - if (e.getMessage().contains("already exists")) { - // FIXME - we need to test that this response always comes back with "already exists" in the message - log.warn(String.format("Attempted to create a user with email (%s) that already existed!", email)); - JSONObject existingMailJetAccount = getAccountByIdOrEmail(email); - return Integer.toString(existingMailJetAccount.getInt("ID")); + // Check if user already exists + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { + log.info("User already exists in Mailjet for email: {}. Fetching existing account.", + maskEmail(normalizedEmail)); + + try { + JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); + if (existingAccount != null) { + String mailjetId = Integer.toString(existingAccount.getInt("ID")); + log.info("Retrieved existing Mailjet account {} for email: {}", + mailjetId, maskEmail(normalizedEmail)); + return mailjetId; + } else { + log.error("User reported as existing but couldn't fetch account for: {}", + maskEmail(normalizedEmail)); + throw new MailjetException("Account exists but couldn't be retrieved"); + } + } catch (JSONException je) { + log.error("JSON parsing error when retrieving existing account for: {}", + maskEmail(normalizedEmail), je); + throw new MailjetException("Failed to parse existing account data", je); + } } else { - log.error(String.format("Failed to create user in MailJet with email: %s", email), e); + log.error("Failed to create Mailjet account for: {}. Error: {}", + maskEmail(normalizedEmail), e.getMessage(), e); + throw new MailjetException("Failed to create account: " + e.getMessage(), e); } + + } catch (MailjetException e) { + // Check if it's a timeout/communication issue + if (e.getMessage() != null && + (e.getMessage().toLowerCase().contains("timeout") || + e.getMessage().toLowerCase().contains("connection"))) { + log.error("Communication error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + log.error("Error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); + throw e; + } catch (JSONException e) { - log.error(String.format("Failed to create user in MailJet with email: %s", email), e); + log.error("JSON parsing error when creating account for: {}", maskEmail(normalizedEmail), e); + throw new MailjetException("Failed to parse Mailjet response", e); } - return null; } /** @@ -141,22 +255,51 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce * @param firstName - first name of user for contact details * @param role - role of user for contact details * @param emailVerificationStatus - verification status of user for contact details - * @param stage - stages of GCSE or ALevel + * @param stage - stages of GCSE or A Level * @throws MailjetException - if underlying MailjetClient throws an exception */ - public void updateUserProperties(final String mailjetId, final String firstName, final String role, - final String emailVerificationStatus, String stage) throws MailjetException { - requireNonNull(mailjetId); - MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId) - .property(Contactdata.DATA, new JSONArray() - .put(new JSONObject().put("Name", "firstname").put("value", firstName)) - .put(new JSONObject().put("Name", "role").put("value", role)) - .put(new JSONObject().put("Name", "verification_status").put("value", emailVerificationStatus)) - .put(new JSONObject().put("Name", "stage").put("value", stage)) - ); - MailjetResponse response = mailjetClient.put(request); - if (response.getTotal() != 1) { - throw new MailjetException("Failed to update user!" + response.getTotal()); + public void updateUserProperties(final String mailjetId, final String firstName, + final String role, final String emailVerificationStatus, + final String stage) throws MailjetException { + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + + try { + log.debug("Updating properties for Mailjet account: {} (role={}, stage={}, status={})", + mailjetId, role, stage, emailVerificationStatus); + + MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId) + .property(Contactdata.DATA, new JSONArray() + .put(new JSONObject().put("Name", "firstname").put("value", firstName != null ? firstName : "")) + .put(new JSONObject().put("Name", "role").put("value", role != null ? role : "")) + .put(new JSONObject().put("Name", "verification_status") + .put("value", emailVerificationStatus != null ? emailVerificationStatus : "")) + .put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown")) + ); + + MailjetResponse response = mailjetClient.put(request); + + if (response.getStatus() == 200 && response.getTotal() == 1) { + log.debug("Successfully updated properties for Mailjet account: {}", mailjetId); + } else { + log.error("Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", + mailjetId, response.getStatus(), response.getTotal()); + throw new MailjetException( + String.format("Failed to update user properties. Status: %d, Total: %d", + response.getStatus(), response.getTotal())); + } + + } catch (MailjetException e) { + // Check if it's a timeout/communication issue + if (e.getMessage() != null && + (e.getMessage().toLowerCase().contains("timeout") || + e.getMessage().toLowerCase().contains("connection"))) { + log.error("Communication error updating properties for Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + log.error("Error updating properties for Mailjet account: {}", mailjetId, e); + throw e; } } @@ -168,25 +311,78 @@ public void updateUserProperties(final String mailjetId, final String firstName, * @param eventsEmails - subscription action to take for events emails * @throws MailjetException - if underlying MailjetClient throws an exception */ - public void updateUserSubscriptions(final String mailjetId, final MailJetSubscriptionAction newsEmails, - final MailJetSubscriptionAction eventsEmails) throws MailjetException { - requireNonNull(mailjetId); - MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId) - .property(ContactManagecontactslists.CONTACTSLISTS, new JSONArray() - .put(new JSONObject() - .put(ContactslistImportList.LISTID, legalListId) - .put(ContactslistImportList.ACTION, MailJetSubscriptionAction.FORCE_SUBSCRIBE.getValue())) - .put(new JSONObject() - .put(ContactslistImportList.LISTID, newsListId) - .put(ContactslistImportList.ACTION, newsEmails.getValue())) - .put(new JSONObject() - .put(ContactslistImportList.LISTID, eventsListId) - .put(ContactslistImportList.ACTION, eventsEmails.getValue())) - ); - MailjetResponse response = mailjetClient.post(request); - if (response.getTotal() != 1) { - throw new MailjetException("Failed to update user subscriptions!" + response.getTotal()); + public void updateUserSubscriptions(final String mailjetId, + final MailJetSubscriptionAction newsEmails, + final MailJetSubscriptionAction eventsEmails) + throws MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + + if (newsEmails == null || eventsEmails == null) { + throw new IllegalArgumentException("Subscription actions cannot be null"); + } + + try { + log.debug("Updating subscriptions for Mailjet account: {} (news={}, events={})", + mailjetId, newsEmails, eventsEmails); + + MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId) + .property(ContactManagecontactslists.CONTACTSLISTS, new JSONArray() + .put(new JSONObject() + .put(ContactslistImportList.LISTID, legalListId) + .put(ContactslistImportList.ACTION, MailJetSubscriptionAction.FORCE_SUBSCRIBE.getValue())) + .put(new JSONObject() + .put(ContactslistImportList.LISTID, newsListId) + .put(ContactslistImportList.ACTION, newsEmails.getValue())) + .put(new JSONObject() + .put(ContactslistImportList.LISTID, eventsListId) + .put(ContactslistImportList.ACTION, eventsEmails.getValue())) + ); + + MailjetResponse response = mailjetClient.post(request); + + if (response.getStatus() == 201 && response.getTotal() == 1) { + log.debug("Successfully updated subscriptions for Mailjet account: {}", mailjetId); + } else { + log.error("Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", + mailjetId, response.getStatus(), response.getTotal()); + throw new MailjetException( + String.format("Failed to update user subscriptions. Status: %d, Total: %d", + response.getStatus(), response.getTotal())); + } + + } catch (MailjetException e) { + // Check if it's a timeout/communication issue + if (e.getMessage() != null && + (e.getMessage().toLowerCase().contains("timeout") || + e.getMessage().toLowerCase().contains("connection"))) { + log.error("Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + log.error("Error updating subscriptions for Mailjet account: {}", mailjetId, e); + throw e; } } -} + /** + * Mask email for logging purposes. + */ + private String maskEmail(String email) { + if (email == null || email.isEmpty()) { + return "[empty]"; + } + + int atIndex = email.indexOf('@'); + if (atIndex <= 0) { + return email.substring(0, Math.min(3, email.length())) + "***"; + } + + String localPart = email.substring(0, atIndex); + String domain = email.substring(atIndex); + String masked = localPart.substring(0, Math.min(3, localPart.length())) + "***"; + + return masked + domain; + } +} \ No newline at end of file From e136ec080e974a2c066d0d9704d8da1402b61df5 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 18 Dec 2025 21:52:54 +0200 Subject: [PATCH 02/22] PATCH 9 --- .../SegueGuiceConfigurationModule.java | 2 +- .../util/email/MailJetApiClientWrapper.java | 63 ++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java index ac5cf51c3c..5fdc959b6b 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java @@ -972,7 +972,7 @@ private static StatisticsManager getStatsManager(final UserAccountManager userMa static final String CRON_STRING_0700_DAILY = "0 0 7 * * ?"; static final String CRON_STRING_2000_DAILY = "0 0 20 * * ?"; static final String CRON_STRING_HOURLY = "0 0 * ? * * *"; - static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0 0/4 ? * * *"; + static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0/30 * ? * * *"; static final String CRON_GROUP_NAME_SQL_MAINTENANCE = "SQLMaintenance"; static final String CRON_GROUP_NAME_JAVA_JOB = "JavaJob"; diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index e4918fd7ed..a9a1cedc7d 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -38,10 +38,16 @@ public class MailJetApiClientWrapper { private static final Logger log = LoggerFactory.getLogger(MailJetApiClientWrapper.class); + private static final long DEFAULT_RATE_LIMIT_DELAY_MS = 2000; // 2 seconds between API calls + private final MailjetClient mailjetClient; private final String newsListId; private final String eventsListId; private final String legalListId; + private final long rateLimitDelayMs; + + // Track last API call time for rate limiting + private long lastApiCallTime = 0; /** * Wrapper for MailjetClient class. @@ -56,6 +62,23 @@ public class MailJetApiClientWrapper { public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, final String mailjetNewsListId, final String mailjetEventsListId, final String mailjetLegalListId) { + this(mailjetApiKey, mailjetApiSecret, mailjetNewsListId, mailjetEventsListId, + mailjetLegalListId, DEFAULT_RATE_LIMIT_DELAY_MS); + } + + /** + * Wrapper for MailjetClient class with configurable rate limiting. + * + * @param mailjetApiKey - MailJet API Key + * @param mailjetApiSecret - MailJet API Client Secret + * @param mailjetNewsListId - MailJet list ID for NEWS_AND_UPDATES + * @param mailjetEventsListId - MailJet list ID for EVENTS + * @param mailjetLegalListId - MailJet list ID for legal notices (all users) + * @param rateLimitDelayMs - Delay in milliseconds between API calls (default: 2000ms) + */ + public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, + final String mailjetNewsListId, final String mailjetEventsListId, + final String mailjetLegalListId, final long rateLimitDelayMs) { if (mailjetApiKey == null || mailjetApiSecret == null) { throw new IllegalArgumentException("Mailjet API credentials cannot be null"); @@ -70,9 +93,11 @@ public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetA this.newsListId = mailjetNewsListId; this.eventsListId = mailjetEventsListId; this.legalListId = mailjetLegalListId; + this.rateLimitDelayMs = rateLimitDelayMs; log.info("MailJet API wrapper initialized with list IDs - News: {}, Events: {}, Legal: {}", newsListId, eventsListId, legalListId); + log.info("Rate limiting enabled: {}ms delay between API calls", rateLimitDelayMs); } /** @@ -88,6 +113,8 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma return null; } + waitForRateLimit(); // Apply rate limiting + try { log.debug("Fetching Mailjet account: {}", mailjetIdOrEmail); MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); @@ -105,7 +132,7 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma } JSONArray responseData = response.getData(); - if (response.getTotal() == 1 && !responseData.isEmpty()) { + if (response.getTotal() == 1 && responseData.length() > 0) { log.debug("Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); return responseData.getJSONObject(0); } @@ -137,6 +164,8 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); } + waitForRateLimit(); // Apply rate limiting + try { log.info("Deleting Mailjet account: {}", mailjetId); MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); @@ -183,6 +212,8 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce String normalizedEmail = email.trim().toLowerCase(); + waitForRateLimit(); // Apply rate limiting + try { log.debug("Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); @@ -265,6 +296,8 @@ public void updateUserProperties(final String mailjetId, final String firstName, throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); } + waitForRateLimit(); // Apply rate limiting + try { log.debug("Updating properties for Mailjet account: {} (role={}, stage={}, status={})", mailjetId, role, stage, emailVerificationStatus); @@ -324,6 +357,8 @@ public void updateUserSubscriptions(final String mailjetId, throw new IllegalArgumentException("Subscription actions cannot be null"); } + waitForRateLimit(); // Apply rate limiting + try { log.debug("Updating subscriptions for Mailjet account: {} (news={}, events={})", mailjetId, newsEmails, eventsEmails); @@ -385,4 +420,30 @@ private String maskEmail(String email) { return masked + domain; } + + /** + * Wait for rate limiting before making an API call. + * Ensures minimum delay between consecutive API calls to avoid rate limits. + * + * This method is synchronized to ensure thread-safety when multiple threads + * might be using the same MailJetApiClientWrapper instance. + */ + private synchronized void waitForRateLimit() { + long currentTime = System.currentTimeMillis(); + long timeSinceLastCall = currentTime - lastApiCallTime; + + if (timeSinceLastCall < rateLimitDelayMs && lastApiCallTime > 0) { + long waitTime = rateLimitDelayMs - timeSinceLastCall; + log.debug("Rate limiting: waiting {}ms before next API call", waitTime); + + try { + Thread.sleep(waitTime); + } catch (InterruptedException e) { + log.warn("Rate limit wait interrupted", e); + Thread.currentThread().interrupt(); + } + } + + lastApiCallTime = System.currentTimeMillis(); + } } \ No newline at end of file From 89aedb66c488dabfc37750d7e184a2931bd7b797 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 18 Dec 2025 22:09:59 +0200 Subject: [PATCH 03/22] PATCH 10 --- .../api/managers/ExternalAccountManager.java | 12 +++++------ .../PgExternalAccountPersistenceManager.java | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index 01fdbfd943..0d90d2abc4 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -81,10 +81,10 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro Long userId = userRecord.getUserId(); try { - log.debug("Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); + log.info("Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); processUserSync(userRecord, metrics); metrics.incrementSuccess(); - log.debug("Successfully processed user ID: {}", userId); + log.info("Successfully processed user ID: {}", userId); } catch (SegueDatabaseException e) { metrics.incrementDatabaseError(); @@ -152,7 +152,7 @@ private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics // Update the provider_last_updated timestamp on success database.updateProviderLastUpdated(userId); - log.debug("Updated provider_last_updated timestamp for user ID: {}", userId); + log.info("Updated provider_last_updated timestamp for user ID: {}", userId); } /** @@ -202,7 +202,7 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan metrics.incrementEmailChanged(); } else { - log.debug("User ID {} has updated details/preferences. Updating Mailjet.", userId); + log.info("User ID {} has updated details/preferences. Updating Mailjet.", userId); updateUserOnMailJet(mailjetId, userRecord); metrics.incrementUpdated(); } @@ -232,7 +232,7 @@ private void handleNewMailjetUser(UserExternalAccountChanges userRecord, metrics.incrementCreated(); } else { - log.debug("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", + log.info("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", userId, userRecord.isDeleted(), accountEmailDeliveryFailed); database.updateExternalAccount(userId, null); metrics.incrementSkipped(); @@ -275,7 +275,7 @@ private void updateUserOnMailJet(final String mailjetId, final UserExternalAccou // Store the Mailjet ID in the database database.updateExternalAccount(userId, mailjetId); - log.debug("Updated Mailjet account {} for user ID {} (news={}, events={})", + log.info("Updated Mailjet account {} for user ID {} (news={}, events={})", mailjetId, userId, newsStatus, eventsStatus); } diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index 44ed78e7d0..ed6d810be8 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -73,7 +73,7 @@ public List getRecentlyChangedRecords() throws Segue try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { - log.debug("Executing query to fetch recently changed user records"); + log.info("Executing query to fetch recently changed user records"); try (ResultSet results = pst.executeQuery()) { List listOfResults = new ArrayList<>(); @@ -123,7 +123,7 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc log.warn("No rows updated when setting provider_last_updated for user ID: {}. " + "User may not have an external_accounts record yet.", userId); } else { - log.debug("Updated provider_last_updated for user ID: {}", userId); + log.info("Updated provider_last_updated for user ID: {}", userId); } } catch (SQLException e) { @@ -155,7 +155,7 @@ public void updateExternalAccount(final Long userId, final String providerUserId int rowsAffected = pst.executeUpdate(); if (rowsAffected > 0) { - log.debug("Upserted external_account for user ID: {} with Mailjet ID: {}", + log.info("Upserted external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { log.warn("Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); @@ -219,12 +219,12 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, if (wasNull) { // User has no preference set - treat as null (not subscribed) - log.debug("User ID {} has NULL preference for {}. Treating as not subscribed.", + log.info("User ID {} has NULL preference for {}. Treating as not subscribed.", userId, preferenceName); return null; } - log.debug("User ID {} has preference {} = {}", userId, preferenceName, value); + log.info("User ID {} has preference {} = {}", userId, preferenceName, value); return value; } @@ -243,7 +243,7 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, */ private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { - log.debug("User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); + log.info("User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -251,7 +251,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // Check for empty JSON object or array if ("{}".equals(trimmed) || "[]".equals(trimmed)) { - log.debug("User ID {} has empty registered_contexts '{}'. Stage: unknown", userId, trimmed); + log.info("User ID {} has empty registered_contexts '{}'. Stage: unknown", userId, trimmed); return "unknown"; } @@ -282,7 +282,7 @@ private String extractStageFromJsonArray(Long userId, String jsonArrayString) th JSONArray array = new JSONArray(jsonArrayString); if (array.isEmpty()) { - log.debug("User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); + log.info("User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -294,7 +294,7 @@ private String extractStageFromJsonArray(Long userId, String jsonArrayString) th if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); - log.debug("User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + log.info("User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", userId, stage, i, normalized); return normalized; } @@ -321,7 +321,7 @@ private String extractStageFromJsonObject(Long userId, String jsonObjectString) if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); - log.debug("User ID {} has stage '{}' in registered_contexts. Normalized: {}", + log.info("User ID {} has stage '{}' in registered_contexts. Normalized: {}", userId, stage, normalized); return normalized; } From 51ae44290d1524862b95da01b3a28fe736ef3a95 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 19 Dec 2025 10:14:02 +0200 Subject: [PATCH 04/22] PATCH 11 --- .../api/managers/ExternalAccountManager.java | 86 ++++++------- .../PgExternalAccountPersistenceManager.java | 48 +++---- .../util/email/MailJetApiClientWrapper.java | 119 ++++++++++++------ 3 files changed, 143 insertions(+), 110 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index 0d90d2abc4..91e2e1546e 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -30,7 +30,6 @@ import uk.ac.cam.cl.dtg.util.email.MailJetApiClientWrapper; import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; - public class ExternalAccountManager implements IExternalAccountManager { private static final Logger log = LoggerFactory.getLogger(ExternalAccountManager.class); @@ -59,19 +58,19 @@ public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IE */ @Override public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { - log.info("Starting Mailjet synchronization process"); + log.info("MAILJETT - Starting Mailjet synchronization process"); List userRecordsToUpdate; try { userRecordsToUpdate = database.getRecentlyChangedRecords(); - log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); + log.info("MAILJETT - Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); } catch (SegueDatabaseException e) { - log.error("Database error whilst collecting users whose details have changed!", e); - throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization: " + e); + log.error("MAILJETT - Database error whilst collecting users whose details have changed!", e); + throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); } if (userRecordsToUpdate.isEmpty()) { - log.info("No users to synchronize. Exiting."); + log.info("MAILJETT - No users to synchronize. Exiting."); return; } @@ -81,41 +80,38 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro Long userId = userRecord.getUserId(); try { - log.info("Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); + log.info("MAILJETT - Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); processUserSync(userRecord, metrics); metrics.incrementSuccess(); - log.info("Successfully processed user ID: {}", userId); + log.info("MAILJETT - Successfully processed user ID: {}", userId); } catch (SegueDatabaseException e) { metrics.incrementDatabaseError(); - log.error("Database error storing Mailjet update for user ID: {}. Error: {}", + log.error("MAILJETT - Database error storing Mailjet update for user ID: {}. Error: {}", userId, e.getMessage(), e); // Continue processing other users - database errors shouldn't stop the entire sync } catch (MailjetClientCommunicationException e) { metrics.incrementCommunicationError(); - log.error("Failed to communicate with Mailjet while processing user ID: {}. Error: {}", + log.error("MAILJETT - Failed to communicate with Mailjet while processing user ID: {}. Error: {}", userId, e.getMessage(), e); throw new ExternalAccountSynchronisationException( "Failed to successfully connect to Mailjet" + e); } catch (MailjetRateLimitException e) { metrics.incrementRateLimitError(); - log.warn("Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit.", + log.warn("MAILJETT - Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit.", userId, metrics.getSuccessCount()); throw new ExternalAccountSynchronisationException( "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); } catch (MailjetException e) { metrics.incrementMailjetError(); - log.error("Mailjet API error while processing user ID: {}. Error: {}", + log.error("MAILJETT - Mailjet API error while processing user ID: {}. Error: {}. Continuing with next user.", userId, e.getMessage(), e); - throw new ExternalAccountSynchronisationException( - "Mailjet API error: " + e.getMessage() + e); - } catch (Exception e) { metrics.incrementUnexpectedError(); - log.error("Unexpected error processing user ID: {}. Error: {}", + log.error("MAILJETT - Unexpected error processing user ID: {}. Error: {}", userId, e.getMessage(), e); // Don't throw - log and continue to avoid blocking all syncs } @@ -135,7 +131,7 @@ private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics // Validate required fields if (accountEmail == null || accountEmail.trim().isEmpty()) { - log.warn("User ID {} has null or empty email address. Skipping.", userId); + log.warn("MAILJETT - User ID {} has null or empty email address. Skipping.", userId); metrics.incrementSkipped(); return; } @@ -152,7 +148,7 @@ private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics // Update the provider_last_updated timestamp on success database.updateProviderLastUpdated(userId); - log.info("Updated provider_last_updated timestamp for user ID: {}", userId); + log.info("MAILJETT - Updated provider_last_updated timestamp for user ID: {}", userId); } /** @@ -168,7 +164,7 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); if (mailjetDetails == null) { - log.warn("User ID {} has Mailjet ID {} but account not found in Mailjet. Treating as new user.", + log.warn("MAILJETT - User ID {} has Mailjet ID {} but account not found in Mailjet. Treating as new user.", userId, mailjetId); // Mailjet account doesn't exist - clear the ID and treat as new database.updateExternalAccount(userId, null); @@ -177,19 +173,19 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan } if (userRecord.isDeleted()) { - log.info("User ID {} is deleted. Removing from Mailjet.", userId); + log.info("MAILJETT - User ID {} is deleted. Removing from Mailjet.", userId); deleteUserFromMailJet(mailjetId, userRecord); metrics.incrementDeleted(); } else if (accountEmailDeliveryFailed) { - log.info("User ID {} has delivery failed status. Unsubscribing from all lists.", userId); + log.info("MAILJETT - User ID {} has delivery failed status. Unsubscribing from all lists.", userId); mailjetApi.updateUserSubscriptions(mailjetId, MailJetSubscriptionAction.REMOVE, MailJetSubscriptionAction.REMOVE); metrics.incrementUnsubscribed(); } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { - log.info("User ID {} changed email from {} to {}. Recreating Mailjet account.", + log.info("MAILJETT - User ID {} changed email from {} to {}. Recreating Mailjet account.", userId, maskEmail(mailjetDetails.getString("Email")), maskEmail(accountEmail)); mailjetApi.permanentlyDeleteAccountById(mailjetId); String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); @@ -202,7 +198,7 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan metrics.incrementEmailChanged(); } else { - log.info("User ID {} has updated details/preferences. Updating Mailjet.", userId); + log.info("MAILJETT - User ID {} has updated details/preferences. Updating Mailjet.", userId); updateUserOnMailJet(mailjetId, userRecord); metrics.incrementUpdated(); } @@ -218,13 +214,13 @@ private void handleNewMailjetUser(UserExternalAccountChanges userRecord, Long userId = userRecord.getUserId(); if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { - log.info("Creating new Mailjet account for user ID {} with email {}", + log.info("MAILJETT - Creating new Mailjet account for user ID {} with email {}", userId, maskEmail(accountEmail)); String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); if (mailjetId == null) { - log.error("Failed to create Mailjet account for user ID {}. Mailjet returned null ID.", userId); + log.error("MAILJETT - Failed to create Mailjet account for user ID {}. Mailjet returned null ID.", userId); throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); } @@ -232,7 +228,7 @@ private void handleNewMailjetUser(UserExternalAccountChanges userRecord, metrics.incrementCreated(); } else { - log.info("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", + log.info("MAILJETT - User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", userId, userRecord.isDeleted(), accountEmailDeliveryFailed); database.updateExternalAccount(userId, null); metrics.incrementSkipped(); @@ -275,7 +271,7 @@ private void updateUserOnMailJet(final String mailjetId, final UserExternalAccou // Store the Mailjet ID in the database database.updateExternalAccount(userId, mailjetId); - log.info("Updated Mailjet account {} for user ID {} (news={}, events={})", + log.info("MAILJETT - Updated Mailjet account {} for user ID {} (news={}, events={})", mailjetId, userId, newsStatus, eventsStatus); } @@ -286,7 +282,7 @@ private void deleteUserFromMailJet(final String mailjetId, final UserExternalAcc throws SegueDatabaseException, MailjetException { if (mailjetId == null || mailjetId.trim().isEmpty()) { - log.warn("Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); + log.warn("MAILJETT - Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); return; } @@ -294,7 +290,7 @@ private void deleteUserFromMailJet(final String mailjetId, final UserExternalAcc mailjetApi.permanentlyDeleteAccountById(mailjetId); database.updateExternalAccount(userId, null); - log.info("Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); + log.info("MAILJETT - Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); } /** @@ -321,22 +317,22 @@ private String maskEmail(String email) { * Log summary of synchronization results. */ private void logSyncSummary(SyncMetrics metrics, int totalUsers) { - log.info("=== Mailjet Synchronization Complete ==="); - log.info("Total users to process: {}", totalUsers); - log.info("Successfully processed: {}", metrics.getSuccessCount()); - log.info(" - Created: {}", metrics.getCreatedCount()); - log.info(" - Updated: {}", metrics.getUpdatedCount()); - log.info(" - Deleted: {}", metrics.getDeletedCount()); - log.info(" - Email changed: {}", metrics.getEmailChangedCount()); - log.info(" - Unsubscribed: {}", metrics.getUnsubscribedCount()); - log.info(" - Skipped: {}", metrics.getSkippedCount()); - log.info("Errors:"); - log.info(" - Database errors: {}", metrics.getDatabaseErrorCount()); - log.info(" - Communication errors: {}", metrics.getCommunicationErrorCount()); - log.info(" - Rate limit errors: {}", metrics.getRateLimitErrorCount()); - log.info(" - Mailjet API errors: {}", metrics.getMailjetErrorCount()); - log.info(" - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); - log.info("========================================"); + log.info("MAILJETT - === Mailjet Synchronization Complete ==="); + log.info("MAILJETT - Total users to process: {}", totalUsers); + log.info("MAILJETT - Successfully processed: {}", metrics.getSuccessCount()); + log.info("MAILJETT - - Created: {}", metrics.getCreatedCount()); + log.info("MAILJETT - - Updated: {}", metrics.getUpdatedCount()); + log.info("MAILJETT - - Deleted: {}", metrics.getDeletedCount()); + log.info("MAILJETT - - Email changed: {}", metrics.getEmailChangedCount()); + log.info("MAILJETT - - Unsubscribed: {}", metrics.getUnsubscribedCount()); + log.info("MAILJETT - - Skipped: {}", metrics.getSkippedCount()); + log.info("MAILJETT - Errors:"); + log.info("MAILJETT - - Database errors: {}", metrics.getDatabaseErrorCount()); + log.info("MAILJETT - - Communication errors: {}", metrics.getCommunicationErrorCount()); + log.info("MAILJETT - - Rate limit errors: {}", metrics.getRateLimitErrorCount()); + log.info("MAILJETT - - Mailjet API errors: {}", metrics.getMailjetErrorCount()); + log.info("MAILJETT - - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); + log.info("MAILJETT - ========================================"); } /** diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index ed6d810be8..deac1c6049 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -73,7 +73,7 @@ public List getRecentlyChangedRecords() throws Segue try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { - log.info("Executing query to fetch recently changed user records"); + log.info("MAILJETT - Executing query to fetch recently changed user records"); try (ResultSet results = pst.executeQuery()) { List listOfResults = new ArrayList<>(); @@ -85,17 +85,17 @@ public List getRecentlyChangedRecords() throws Segue } catch (SQLException | JSONException e) { // Log but continue processing other users long userId = results.getLong("id"); - log.error("Error building UserExternalAccountChanges for user ID: {}. Error: {}", + log.error("MAILJETT - Error building UserExternalAccountChanges for user ID: {}. Error: {}", userId, e.getMessage(), e); } } - log.info("Retrieved {} user records requiring synchronization", listOfResults.size()); + log.info("MAILJETT - Retrieved {} user records requiring synchronization", listOfResults.size()); return listOfResults; } } catch (SQLException e) { - log.error("Database error while fetching recently changed records", e); + log.error("MAILJETT - Database error while fetching recently changed records", e); throw new SegueDatabaseException("Failed to retrieve recently changed user records", e); } } @@ -120,14 +120,14 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc int rowsUpdated = pst.executeUpdate(); if (rowsUpdated == 0) { - log.warn("No rows updated when setting provider_last_updated for user ID: {}. " + log.warn("MAILJETT - No rows updated when setting provider_last_updated for user ID: {}. " + "User may not have an external_accounts record yet.", userId); } else { - log.info("Updated provider_last_updated for user ID: {}", userId); + log.info("MAILJETT - Updated provider_last_updated for user ID: {}", userId); } } catch (SQLException e) { - log.error("Database error updating provider_last_updated for user ID: {}", userId, e); + log.error("MAILJETT - Database error updating provider_last_updated for user ID: {}", userId, e); throw new SegueDatabaseException("Failed to update provider_last_updated for user: " + userId, e); } } @@ -155,14 +155,14 @@ public void updateExternalAccount(final Long userId, final String providerUserId int rowsAffected = pst.executeUpdate(); if (rowsAffected > 0) { - log.info("Upserted external_account for user ID: {} with Mailjet ID: {}", + log.info("MAILJETT - Upserted external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { - log.warn("Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); + log.warn("MAILJETT - Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); } } catch (SQLException e) { - log.error("Database error upserting external_account for user ID: {} with Mailjet ID: {}", + log.error("MAILJETT - Database error upserting external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier, e); throw new SegueDatabaseException( "Failed to upsert external_account for user: " + userId, e); @@ -219,12 +219,12 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, if (wasNull) { // User has no preference set - treat as null (not subscribed) - log.info("User ID {} has NULL preference for {}. Treating as not subscribed.", + log.info("MAILJETT - User ID {} has NULL preference for {}. Treating as not subscribed.", userId, preferenceName); return null; } - log.info("User ID {} has preference {} = {}", userId, preferenceName, value); + log.info("MAILJETT - User ID {} has preference {} = {}", userId, preferenceName, value); return value; } @@ -243,7 +243,7 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, */ private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { - log.info("User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); + log.info("MAILJETT - User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -251,7 +251,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // Check for empty JSON object or array if ("{}".equals(trimmed) || "[]".equals(trimmed)) { - log.info("User ID {} has empty registered_contexts '{}'. Stage: unknown", userId, trimmed); + log.info("MAILJETT - User ID {} has empty registered_contexts '{}'. Stage: unknown", userId, trimmed); return "unknown"; } @@ -263,13 +263,13 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // Single JSON object (less common but possible) return extractStageFromJsonObject(userId, trimmed); } else { - log.warn("User ID {} has unexpected registered_contexts format (not JSON): '{}'. Stage: unknown", + log.warn("MAILJETT - User ID {} has unexpected registered_contexts format (not JSON): '{}'. Stage: unknown", userId, truncateForLog(registeredContextsJson)); return "unknown"; } } catch (JSONException e) { - log.warn("User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", + log.warn("MAILJETT - User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", userId, truncateForLog(registeredContextsJson), e.getMessage()); return "unknown"; } @@ -282,7 +282,7 @@ private String extractStageFromJsonArray(Long userId, String jsonArrayString) th JSONArray array = new JSONArray(jsonArrayString); if (array.isEmpty()) { - log.info("User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); + log.info("MAILJETT - User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -294,7 +294,7 @@ private String extractStageFromJsonArray(Long userId, String jsonArrayString) th if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); - log.info("User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + log.info("MAILJETT - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", userId, stage, i, normalized); return normalized; } @@ -304,9 +304,9 @@ private String extractStageFromJsonArray(Long userId, String jsonArrayString) th // No 'stage' key found, use fallback pattern matching String fallbackStage = fallbackStageDetection(userId, jsonArrayString); if (!"unknown".equals(fallbackStage)) { - log.info("User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + log.info("MAILJETT - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); } else { - log.warn("User ID {} has registered_contexts array but no 'stage' key found: {}. Stage: unknown", + log.warn("MAILJETT - User ID {} has registered_contexts array but no 'stage' key found: {}. Stage: unknown", userId, truncateForLog(jsonArrayString)); } return fallbackStage; @@ -321,7 +321,7 @@ private String extractStageFromJsonObject(Long userId, String jsonObjectString) if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); - log.info("User ID {} has stage '{}' in registered_contexts. Normalized: {}", + log.info("MAILJETT - User ID {} has stage '{}' in registered_contexts. Normalized: {}", userId, stage, normalized); return normalized; } @@ -329,9 +329,9 @@ private String extractStageFromJsonObject(Long userId, String jsonObjectString) // No 'stage' key found, use fallback pattern matching String fallbackStage = fallbackStageDetection(userId, jsonObjectString); if (!"unknown".equals(fallbackStage)) { - log.info("User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + log.info("MAILJETT - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); } else { - log.warn("User ID {} has registered_contexts object but no 'stage' key: {}. Stage: unknown", + log.warn("MAILJETT - User ID {} has registered_contexts object but no 'stage' key: {}. Stage: unknown", userId, truncateForLog(jsonObjectString)); } return fallbackStage; @@ -383,7 +383,7 @@ private String normalizeStage(String stage) { return "GCSE and A Level"; default: // Warn about unexpected stage values - log.warn("Unexpected stage value '{}' encountered. Returning 'unknown'. " + log.warn("MAILJETT - Unexpected stage value '{}' encountered. Returning 'unknown'. " + "Expected values: gcse, a_level, gcse_and_a_level, both", stage); return "unknown"; } diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index a9a1cedc7d..8dc27fe735 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -95,9 +95,9 @@ public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetA this.legalListId = mailjetLegalListId; this.rateLimitDelayMs = rateLimitDelayMs; - log.info("MailJet API wrapper initialized with list IDs - News: {}, Events: {}, Legal: {}", + log.info("MAILJETT - MailJet API wrapper initialized with list IDs - News: {}, Events: {}, Legal: {}", newsListId, eventsListId, legalListId); - log.info("Rate limiting enabled: {}ms delay between API calls", rateLimitDelayMs); + log.info("MAILJETT - Rate limiting enabled: {}ms delay between API calls", rateLimitDelayMs); } /** @@ -109,46 +109,56 @@ public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetA */ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { - log.debug("Attempted to get account with null/empty identifier"); + log.info("MAILJETT - Attempted to get account with null/empty identifier"); return null; } waitForRateLimit(); // Apply rate limiting try { - log.debug("Fetching Mailjet account: {}", mailjetIdOrEmail); + log.info("MAILJETT - Fetching Mailjet account: {}", mailjetIdOrEmail); MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); MailjetResponse response = mailjetClient.get(request); if (response.getStatus() == 404) { - log.debug("Mailjet account not found: {}", mailjetIdOrEmail); + log.info("MAILJETT - Mailjet account not found: {}", mailjetIdOrEmail); return null; } if (response.getStatus() != 200) { - log.warn("Unexpected Mailjet response status {} when fetching account: {}", + log.warn("MAILJETT - Unexpected Mailjet response status {} when fetching account: {}", response.getStatus(), mailjetIdOrEmail); throw new MailjetException("Unexpected response status: " + response.getStatus()); } JSONArray responseData = response.getData(); - if (response.getTotal() == 1 && responseData.length() > 0) { - log.debug("Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); + if (response.getTotal() == 1 && !responseData.isEmpty()) { + log.info("MAILJETT - Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); return responseData.getJSONObject(0); } - log.debug("Mailjet account not found (total={}): {}", response.getTotal(), mailjetIdOrEmail); + log.info("MAILJETT - Mailjet account not found (total={}): {}", response.getTotal(), mailjetIdOrEmail); return null; } catch (MailjetException e) { + // Check if it's a 404 "Object not found" error + if (e.getMessage() != null && + (e.getMessage().contains("404") || + e.getMessage().toLowerCase().contains("not found") || + e.getMessage().toLowerCase().contains("object not found"))) { + log.info("MAILJETT - Mailjet account not found (404): {}. Error: {}", mailjetIdOrEmail, e.getMessage()); + return null; // Treat 404 as "not found", not an error + } + // Check if it's a timeout/communication issue if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("Communication error fetching Mailjet account: {}", mailjetIdOrEmail, e); + log.error("MAILJETT - Communication error fetching Mailjet account: {}", mailjetIdOrEmail, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("Error fetching Mailjet account: {}", mailjetIdOrEmail, e); + + log.error("MAILJETT - Error fetching Mailjet account: {}", mailjetIdOrEmail, e); throw e; } } @@ -167,30 +177,39 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE waitForRateLimit(); // Apply rate limiting try { - log.info("Deleting Mailjet account: {}", mailjetId); + log.info("MAILJETT - Deleting Mailjet account: {}", mailjetId); MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); MailjetResponse response = mailjetClient.delete(request); if (response.getStatus() == 204 || response.getStatus() == 200) { - log.info("Successfully deleted Mailjet account: {}", mailjetId); + log.info("MAILJETT - Successfully deleted Mailjet account: {}", mailjetId); } else if (response.getStatus() == 404) { - log.warn("Attempted to delete non-existent Mailjet account: {}", mailjetId); + log.warn("MAILJETT - Attempted to delete non-existent Mailjet account: {}", mailjetId); // Don't throw - account is already gone } else { - log.error("Unexpected response status {} when deleting Mailjet account: {}", + log.error("MAILJETT - Unexpected response status {} when deleting Mailjet account: {}", response.getStatus(), mailjetId); throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); } } catch (MailjetException e) { + // Check if it's a 404 - account already deleted + if (e.getMessage() != null && + (e.getMessage().contains("404") || + e.getMessage().toLowerCase().contains("not found") || + e.getMessage().toLowerCase().contains("object not found"))) { + log.warn("MAILJETT - Mailjet account already deleted or not found: {}. Treating as success.", mailjetId); + return; // Already deleted - treat as success + } + // Check if it's a timeout/communication issue if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("Communication error deleting Mailjet account: {}", mailjetId, e); + log.error("MAILJETT - Communication error deleting Mailjet account: {}", mailjetId, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("Error deleting Mailjet account: {}", mailjetId, e); + log.error("MAILJETT - Error deleting Mailjet account: {}", mailjetId, e); throw e; } } @@ -206,7 +225,7 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE */ public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { if (email == null || email.trim().isEmpty()) { - log.warn("Attempted to create Mailjet account with null/empty email"); + log.warn("MAILJETT - Attempted to create Mailjet account with null/empty email"); return null; } @@ -215,7 +234,7 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce waitForRateLimit(); // Apply rate limiting try { - log.debug("Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); + log.info("MAILJETT - Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); MailjetRequest request = new MailjetRequest(Contact.resource) .property(Contact.EMAIL, normalizedEmail); @@ -224,40 +243,40 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce if (response.getStatus() == 201 || response.getStatus() == 200) { JSONObject responseData = response.getData().getJSONObject(0); String mailjetId = Integer.toString(responseData.getInt("ID")); - log.info("Successfully created Mailjet account {} for email: {}", + log.info("MAILJETT - Successfully created Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; } - log.error("Unexpected response status {} when creating Mailjet account for: {}", + log.error("MAILJETT - Unexpected response status {} when creating Mailjet account for: {}", response.getStatus(), maskEmail(normalizedEmail)); throw new MailjetException("Failed to create account. Status: " + response.getStatus()); } catch (MailjetClientRequestException e) { // Check if user already exists if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { - log.info("User already exists in Mailjet for email: {}. Fetching existing account.", + log.info("MAILJETT - User already exists in Mailjet for email: {}. Fetching existing account.", maskEmail(normalizedEmail)); try { JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); if (existingAccount != null) { String mailjetId = Integer.toString(existingAccount.getInt("ID")); - log.info("Retrieved existing Mailjet account {} for email: {}", + log.info("MAILJETT - Retrieved existing Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; } else { - log.error("User reported as existing but couldn't fetch account for: {}", + log.error("MAILJETT - User reported as existing but couldn't fetch account for: {}", maskEmail(normalizedEmail)); throw new MailjetException("Account exists but couldn't be retrieved"); } } catch (JSONException je) { - log.error("JSON parsing error when retrieving existing account for: {}", + log.error("MAILJETT - JSON parsing error when retrieving existing account for: {}", maskEmail(normalizedEmail), je); throw new MailjetException("Failed to parse existing account data", je); } } else { - log.error("Failed to create Mailjet account for: {}. Error: {}", + log.error("MAILJETT - Failed to create Mailjet account for: {}. Error: {}", maskEmail(normalizedEmail), e.getMessage(), e); throw new MailjetException("Failed to create account: " + e.getMessage(), e); } @@ -267,14 +286,14 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("Communication error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); + log.error("MAILJETT - Communication error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("Error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); + log.error("MAILJETT - Error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); throw e; } catch (JSONException e) { - log.error("JSON parsing error when creating account for: {}", maskEmail(normalizedEmail), e); + log.error("MAILJETT - JSON parsing error when creating account for: {}", maskEmail(normalizedEmail), e); throw new MailjetException("Failed to parse Mailjet response", e); } } @@ -299,7 +318,7 @@ public void updateUserProperties(final String mailjetId, final String firstName, waitForRateLimit(); // Apply rate limiting try { - log.debug("Updating properties for Mailjet account: {} (role={}, stage={}, status={})", + log.info("MAILJETT - Updating properties for Mailjet account: {} (role={}, stage={}, status={})", mailjetId, role, stage, emailVerificationStatus); MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId) @@ -314,9 +333,9 @@ public void updateUserProperties(final String mailjetId, final String firstName, MailjetResponse response = mailjetClient.put(request); if (response.getStatus() == 200 && response.getTotal() == 1) { - log.debug("Successfully updated properties for Mailjet account: {}", mailjetId); + log.info("MAILJETT - Successfully updated properties for Mailjet account: {}", mailjetId); } else { - log.error("Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", + log.error("MAILJETT - Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); throw new MailjetException( String.format("Failed to update user properties. Status: %d, Total: %d", @@ -324,14 +343,23 @@ public void updateUserProperties(final String mailjetId, final String firstName, } } catch (MailjetException e) { + // Check if it's a 404 - contact not found + if (e.getMessage() != null && + (e.getMessage().contains("404") || + e.getMessage().toLowerCase().contains("not found") || + e.getMessage().toLowerCase().contains("object not found"))) { + log.error("MAILJETT - Mailjet contact not found when updating properties: {}. The contact may have been deleted.", mailjetId); + throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); + } + // Check if it's a timeout/communication issue if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("Communication error updating properties for Mailjet account: {}", mailjetId, e); + log.error("MAILJETT - Communication error updating properties for Mailjet account: {}", mailjetId, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("Error updating properties for Mailjet account: {}", mailjetId, e); + log.error("MAILJETT - Error updating properties for Mailjet account: {}", mailjetId, e); throw e; } } @@ -360,7 +388,7 @@ public void updateUserSubscriptions(final String mailjetId, waitForRateLimit(); // Apply rate limiting try { - log.debug("Updating subscriptions for Mailjet account: {} (news={}, events={})", + log.info("MAILJETT - Updating subscriptions for Mailjet account: {} (news={}, events={})", mailjetId, newsEmails, eventsEmails); MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId) @@ -379,9 +407,9 @@ public void updateUserSubscriptions(final String mailjetId, MailjetResponse response = mailjetClient.post(request); if (response.getStatus() == 201 && response.getTotal() == 1) { - log.debug("Successfully updated subscriptions for Mailjet account: {}", mailjetId); + log.info("MAILJETT - Successfully updated subscriptions for Mailjet account: {}", mailjetId); } else { - log.error("Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", + log.error("MAILJETT - Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); throw new MailjetException( String.format("Failed to update user subscriptions. Status: %d, Total: %d", @@ -389,14 +417,23 @@ public void updateUserSubscriptions(final String mailjetId, } } catch (MailjetException e) { + // Check if it's a 404 - contact not found + if (e.getMessage() != null && + (e.getMessage().contains("404") || + e.getMessage().toLowerCase().contains("not found") || + e.getMessage().toLowerCase().contains("object not found"))) { + log.error("MAILJETT - Mailjet contact not found when updating subscriptions: {}. The contact may have been deleted.", mailjetId); + throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); + } + // Check if it's a timeout/communication issue if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); + log.error("MAILJETT - Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("Error updating subscriptions for Mailjet account: {}", mailjetId, e); + log.error("MAILJETT - Error updating subscriptions for Mailjet account: {}", mailjetId, e); throw e; } } @@ -434,12 +471,12 @@ private synchronized void waitForRateLimit() { if (timeSinceLastCall < rateLimitDelayMs && lastApiCallTime > 0) { long waitTime = rateLimitDelayMs - timeSinceLastCall; - log.debug("Rate limiting: waiting {}ms before next API call", waitTime); + log.info("MAILJETT - Rate limiting: waiting {}ms before next API call", waitTime); try { Thread.sleep(waitTime); } catch (InterruptedException e) { - log.warn("Rate limit wait interrupted", e); + log.warn("MAILJETT - Rate limit wait interrupted", e); Thread.currentThread().interrupt(); } } From 25cc8a9e9b221f32f1b7dbfb03fbda9c77ca5cea Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 19 Dec 2025 11:04:54 +0200 Subject: [PATCH 05/22] PATCH 12 --- .../PgExternalAccountPersistenceManager.java | 139 ++++++------------ 1 file changed, 48 insertions(+), 91 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index deac1c6049..e018a827bf 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -19,6 +19,7 @@ import uk.ac.cam.cl.dtg.isaac.dos.users.UserExternalAccountChanges; import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; + /** * This class is responsible for managing and persisting user data. */ @@ -39,8 +40,8 @@ public PgExternalAccountPersistenceManager(final PostgresSqlDb database) { @Override public List getRecentlyChangedRecords() throws SegueDatabaseException { - // Note: registered_contexts is JSONB[] (array of JSONB objects) in PostgreSQL - // We need to cast it to TEXT to parse it properly in Java + // IMPORTANT: registered_contexts is JSONB[] (array of JSONB objects) in PostgreSQL + // We use array_to_json() to convert it to proper JSON that Java can parse String query = "SELECT users.id, " + " external_accounts.provider_user_identifier, " + " users.email, " @@ -48,7 +49,7 @@ public List getRecentlyChangedRecords() throws Segue + " users.given_name, " + " users.deleted, " + " users.email_verification_status, " - + " CAST(users.registered_contexts AS TEXT) AS registered_contexts, " // CAST JSONB[] to TEXT + + " array_to_json(users.registered_contexts) AS registered_contexts, " // Convert JSONB[] to JSON + " news_prefs.preference_value AS news_emails, " + " events_prefs.preference_value AS events_emails, " + " external_accounts.provider_last_updated " @@ -73,7 +74,7 @@ public List getRecentlyChangedRecords() throws Segue try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { - log.info("MAILJETT - Executing query to fetch recently changed user records"); + log.debug("MAILJETT - Executing query to fetch recently changed user records"); try (ResultSet results = pst.executeQuery()) { List listOfResults = new ArrayList<>(); @@ -90,7 +91,7 @@ public List getRecentlyChangedRecords() throws Segue } } - log.info("MAILJETT - Retrieved {} user records requiring synchronization", listOfResults.size()); + log.debug("MAILJETT - Retrieved {} user records requiring synchronization", listOfResults.size()); return listOfResults; } @@ -123,7 +124,7 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc log.warn("MAILJETT - No rows updated when setting provider_last_updated for user ID: {}. " + "User may not have an external_accounts record yet.", userId); } else { - log.info("MAILJETT - Updated provider_last_updated for user ID: {}", userId); + log.debug("MAILJETT - Updated provider_last_updated for user ID: {}", userId); } } catch (SQLException e) { @@ -155,7 +156,7 @@ public void updateExternalAccount(final Long userId, final String providerUserId int rowsAffected = pst.executeUpdate(); if (rowsAffected > 0) { - log.info("MAILJETT - Upserted external_account for user ID: {} with Mailjet ID: {}", + log.debug("MAILJETT - Upserted external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { log.warn("MAILJETT - Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); @@ -219,122 +220,78 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, if (wasNull) { // User has no preference set - treat as null (not subscribed) - log.info("MAILJETT - User ID {} has NULL preference for {}. Treating as not subscribed.", + log.debug("MAILJETT - User ID {} has NULL preference for {}. Treating as not subscribed.", userId, preferenceName); return null; } - log.info("MAILJETT - User ID {} has preference {} = {}", userId, preferenceName, value); + log.debug("MAILJETT - User ID {} has preference {} = {}", userId, preferenceName, value); return value; } /** * Extract stage information from registered_contexts JSONB[] field. * - * PostgreSQL stores this as JSONB[] (array of JSONB objects), which we cast to TEXT in the query. - * The TEXT representation looks like: - * - Single object: '{"stage": "gcse"}' - * - Array of objects: '[{"stage": "gcse"}, {"exam_board": "AQA"}]' - * - Empty: NULL, '{}', or '[]' + * PostgreSQL JSONB[] is converted to JSON using array_to_json() in the query. + * This gives us clean JSON like: [{"stage": "gcse", "examBoard": "aqa"}] * * @param userId User ID for logging - * @param registeredContextsJson JSONB[] field cast to TEXT + * @param registeredContextsJson JSONB[] converted to JSON via array_to_json() * @return stage string: "GCSE", "A Level", "GCSE and A Level", or "unknown" */ private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { - log.info("MAILJETT - User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); + log.debug("MAILJETT - User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); return "unknown"; } String trimmed = registeredContextsJson.trim(); - // Check for empty JSON object or array - if ("{}".equals(trimmed) || "[]".equals(trimmed)) { - log.info("MAILJETT - User ID {} has empty registered_contexts '{}'. Stage: unknown", userId, trimmed); + // Check for empty JSON array + if ("[]".equals(trimmed) || "null".equals(trimmed)) { + log.debug("MAILJETT - User ID {} has empty/null registered_contexts. Stage: unknown", userId); return "unknown"; } try { - // Try to parse as JSONArray first (JSONB[] is typically an array) - if (trimmed.startsWith("[")) { - return extractStageFromJsonArray(userId, trimmed); - } else if (trimmed.startsWith("{")) { - // Single JSON object (less common but possible) - return extractStageFromJsonObject(userId, trimmed); - } else { - log.warn("MAILJETT - User ID {} has unexpected registered_contexts format (not JSON): '{}'. Stage: unknown", - userId, truncateForLog(registeredContextsJson)); + // Parse as JSONArray (array_to_json returns proper JSON array) + JSONArray array = new JSONArray(trimmed); + + if (array.length() == 0) { + log.debug("MAILJETT - User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); return "unknown"; } - } catch (JSONException e) { - log.warn("MAILJETT - User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", - userId, truncateForLog(registeredContextsJson), e.getMessage()); - return "unknown"; - } - } - - /** - * Extract stage from JSON array format: [{"stage": "gcse"}, {...}] - */ - private String extractStageFromJsonArray(Long userId, String jsonArrayString) throws JSONException { - JSONArray array = new JSONArray(jsonArrayString); - - if (array.isEmpty()) { - log.info("MAILJETT - User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); - return "unknown"; - } - - // Check each object in the array for 'stage' key - for (int i = 0; i < array.length(); i++) { - Object item = array.get(i); - if (item instanceof JSONObject) { - JSONObject obj = (JSONObject) item; - if (obj.has("stage")) { - String stage = obj.getString("stage"); - String normalized = normalizeStage(stage); - log.info("MAILJETT - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", - userId, stage, i, normalized); - return normalized; + // Search through array for 'stage' key + for (int i = 0; i < array.length(); i++) { + Object item = array.get(i); + if (item instanceof JSONObject) { + JSONObject obj = (JSONObject) item; + if (obj.has("stage")) { + String stage = obj.getString("stage"); + String normalized = normalizeStage(stage); + log.debug("MAILJETT - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + userId, stage, i, normalized); + return normalized; + } } } - } - - // No 'stage' key found, use fallback pattern matching - String fallbackStage = fallbackStageDetection(userId, jsonArrayString); - if (!"unknown".equals(fallbackStage)) { - log.info("MAILJETT - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); - } else { - log.warn("MAILJETT - User ID {} has registered_contexts array but no 'stage' key found: {}. Stage: unknown", - userId, truncateForLog(jsonArrayString)); - } - return fallbackStage; - } - /** - * Extract stage from JSON object format: {"stage": "gcse", ...} - */ - private String extractStageFromJsonObject(Long userId, String jsonObjectString) throws JSONException { - JSONObject obj = new JSONObject(jsonObjectString); - - if (obj.has("stage")) { - String stage = obj.getString("stage"); - String normalized = normalizeStage(stage); - log.info("MAILJETT - User ID {} has stage '{}' in registered_contexts. Normalized: {}", - userId, stage, normalized); - return normalized; - } + // No 'stage' key found, use fallback pattern matching + String fallbackStage = fallbackStageDetection(userId, trimmed); + if (!"unknown".equals(fallbackStage)) { + log.debug("MAILJETT - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + } else { + log.warn("MAILJETT - User ID {} has registered_contexts but no 'stage' key found: {}. Stage: unknown", + userId, truncateForLog(trimmed)); + } + return fallbackStage; - // No 'stage' key found, use fallback pattern matching - String fallbackStage = fallbackStageDetection(userId, jsonObjectString); - if (!"unknown".equals(fallbackStage)) { - log.info("MAILJETT - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); - } else { - log.warn("MAILJETT - User ID {} has registered_contexts object but no 'stage' key: {}. Stage: unknown", - userId, truncateForLog(jsonObjectString)); + } catch (JSONException e) { + log.warn("MAILJETT - User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", + userId, truncateForLog(registeredContextsJson), e.getMessage()); + return "unknown"; } - return fallbackStage; } /** @@ -401,4 +358,4 @@ private String truncateForLog(String str) { } return str.substring(0, 97) + "..."; } -} \ No newline at end of file +} From 86df1fcfe6798efa9998890e21f00a7838c97098 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 19 Dec 2025 11:17:11 +0200 Subject: [PATCH 06/22] PATCH 13 --- .../uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index 8dc27fe735..01d01dcff8 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -242,7 +242,8 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce if (response.getStatus() == 201 || response.getStatus() == 200) { JSONObject responseData = response.getData().getJSONObject(0); - String mailjetId = Integer.toString(responseData.getInt("ID")); + log.info("MAILJETT - responseData : {}", responseData.toString()); + String mailjetId = responseData.getString("ID"); log.info("MAILJETT - Successfully created Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; From 8113130c34bf644b695cfdf781432a75c8726e7d Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 19 Dec 2025 11:56:29 +0200 Subject: [PATCH 07/22] PATCH 14 --- .../users/PgExternalAccountPersistenceManager.java | 2 ++ .../cl/dtg/util/email/MailJetApiClientWrapper.java | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index e018a827bf..8e7a30025f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -338,6 +338,8 @@ private String normalizeStage(String stage) { case "gcse,a_level": case "gcse, a level": return "GCSE and A Level"; + case "all": + return "ALL"; default: // Warn about unexpected stage values log.warn("MAILJETT - Unexpected stage value '{}' encountered. Returning 'unknown'. " diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index 01d01dcff8..df17df1160 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -120,6 +120,8 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); MailjetResponse response = mailjetClient.get(request); + log.info("MAILJETT - response: {}", response.getRawResponseContent()); + if (response.getStatus() == 404) { log.info("MAILJETT - Mailjet account not found: {}", mailjetIdOrEmail); return null; @@ -181,6 +183,8 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); MailjetResponse response = mailjetClient.delete(request); + log.info("MAILJETT - response: {}", response.getRawResponseContent()); + if (response.getStatus() == 204 || response.getStatus() == 200) { log.info("MAILJETT - Successfully deleted Mailjet account: {}", mailjetId); } else if (response.getStatus() == 404) { @@ -240,6 +244,8 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce .property(Contact.EMAIL, normalizedEmail); MailjetResponse response = mailjetClient.post(request); + log.info("MAILJETT - response: {}", response.getRawResponseContent()); + if (response.getStatus() == 201 || response.getStatus() == 200) { JSONObject responseData = response.getData().getJSONObject(0); log.info("MAILJETT - responseData : {}", responseData.toString()); @@ -262,7 +268,7 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce try { JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); if (existingAccount != null) { - String mailjetId = Integer.toString(existingAccount.getInt("ID")); + String mailjetId = existingAccount.getString("ID"); log.info("MAILJETT - Retrieved existing Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; @@ -333,6 +339,8 @@ public void updateUserProperties(final String mailjetId, final String firstName, MailjetResponse response = mailjetClient.put(request); + log.info("MAILJETT - response: {}", response.getRawResponseContent()); + if (response.getStatus() == 200 && response.getTotal() == 1) { log.info("MAILJETT - Successfully updated properties for Mailjet account: {}", mailjetId); } else { @@ -407,6 +415,8 @@ public void updateUserSubscriptions(final String mailjetId, MailjetResponse response = mailjetClient.post(request); + log.info("MAILJETT - response: {}", response.getRawResponseContent()); + if (response.getStatus() == 201 && response.getTotal() == 1) { log.info("MAILJETT - Successfully updated subscriptions for Mailjet account: {}", mailjetId); } else { From 0343513898b87424e99976023da2339897328193 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 19 Dec 2025 13:49:09 +0200 Subject: [PATCH 08/22] PATCH 15 --- .../uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index df17df1160..d14403fa52 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -249,7 +249,7 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce if (response.getStatus() == 201 || response.getStatus() == 200) { JSONObject responseData = response.getData().getJSONObject(0); log.info("MAILJETT - responseData : {}", responseData.toString()); - String mailjetId = responseData.getString("ID"); + String mailjetId = String.valueOf(responseData.get("ID")); log.info("MAILJETT - Successfully created Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; @@ -268,7 +268,7 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce try { JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); if (existingAccount != null) { - String mailjetId = existingAccount.getString("ID"); + String mailjetId = String.valueOf(existingAccount.get("ID")); log.info("MAILJETT - Retrieved existing Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; From 87711b79c8afc10a71877df953ed8eab5d5ab6a5 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 19 Dec 2025 18:49:05 +0200 Subject: [PATCH 09/22] PATCH 16 --- .../cam/cl/dtg/util/email/MailJetApiClientWrapper.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index d14403fa52..42b046512f 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -118,6 +118,7 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma try { log.info("MAILJETT - Fetching Mailjet account: {}", mailjetIdOrEmail); MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); + log.info("MAILJETT - request: {}", request.getBody()); MailjetResponse response = mailjetClient.get(request); log.info("MAILJETT - response: {}", response.getRawResponseContent()); @@ -180,7 +181,11 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE try { log.info("MAILJETT - Deleting Mailjet account: {}", mailjetId); + MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); + + log.info("MAILJETT - request: {}", request.getBody()); + MailjetResponse response = mailjetClient.delete(request); log.info("MAILJETT - response: {}", response.getRawResponseContent()); @@ -242,6 +247,7 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce MailjetRequest request = new MailjetRequest(Contact.resource) .property(Contact.EMAIL, normalizedEmail); + log.info("MAILJETT - request: {}", request.getBody()); MailjetResponse response = mailjetClient.post(request); log.info("MAILJETT - response: {}", response.getRawResponseContent()); @@ -337,6 +343,8 @@ public void updateUserProperties(final String mailjetId, final String firstName, .put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown")) ); + log.info("MAILJETT - request: {}", request.getBody()); + MailjetResponse response = mailjetClient.put(request); log.info("MAILJETT - response: {}", response.getRawResponseContent()); @@ -413,6 +421,8 @@ public void updateUserSubscriptions(final String mailjetId, .put(ContactslistImportList.ACTION, eventsEmails.getValue())) ); + log.info("MAILJETT - request: {}", request.getBody()); + MailjetResponse response = mailjetClient.post(request); log.info("MAILJETT - response: {}", response.getRawResponseContent()); From 6a97acaa27bb491dd28814f48cbe148bfd2390b9 Mon Sep 17 00:00:00 2001 From: Marius Date: Wed, 24 Dec 2025 00:59:11 +0200 Subject: [PATCH 10/22] PATCH 17 --- .../api/managers/ExternalAccountManager.java | 85 +++++++------- .../PgExternalAccountPersistenceManager.java | 46 ++++---- .../util/email/MailJetApiClientWrapper.java | 107 +++++++----------- 3 files changed, 109 insertions(+), 129 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index 91e2e1546e..949c7c2877 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -58,19 +58,19 @@ public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IE */ @Override public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { - log.info("MAILJETT - Starting Mailjet synchronization process"); + log.info("MAILJET - Starting Mailjet synchronization process"); List userRecordsToUpdate; try { userRecordsToUpdate = database.getRecentlyChangedRecords(); - log.info("MAILJETT - Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); + log.info("MAILJET - Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); } catch (SegueDatabaseException e) { - log.error("MAILJETT - Database error whilst collecting users whose details have changed!", e); + log.error("MAILJET - Database error whilst collecting users whose details have changed!", e); throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); } if (userRecordsToUpdate.isEmpty()) { - log.info("MAILJETT - No users to synchronize. Exiting."); + log.info("MAILJET - No users to synchronize. Exiting."); return; } @@ -80,38 +80,39 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro Long userId = userRecord.getUserId(); try { - log.info("MAILJETT - Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); + log.info("MAILJET - Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); processUserSync(userRecord, metrics); metrics.incrementSuccess(); - log.info("MAILJETT - Successfully processed user ID: {}", userId); + log.info("MAILJET - Successfully processed user ID: {}", userId); } catch (SegueDatabaseException e) { metrics.incrementDatabaseError(); - log.error("MAILJETT - Database error storing Mailjet update for user ID: {}. Error: {}", + log.error("MAILJET - Database error storing Mailjet update for user ID: {}. Error: {}", userId, e.getMessage(), e); // Continue processing other users - database errors shouldn't stop the entire sync } catch (MailjetClientCommunicationException e) { metrics.incrementCommunicationError(); - log.error("MAILJETT - Failed to communicate with Mailjet while processing user ID: {}. Error: {}", + log.error("MAILJET - Failed to communicate with Mailjet while processing user ID: {}. Error: {}", userId, e.getMessage(), e); throw new ExternalAccountSynchronisationException( "Failed to successfully connect to Mailjet" + e); } catch (MailjetRateLimitException e) { metrics.incrementRateLimitError(); - log.warn("MAILJETT - Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit.", + log.warn("MAILJET - Mailjet rate limit exceeded while processing user ID: {}. " + + "Processed {} users before limit.", userId, metrics.getSuccessCount()); throw new ExternalAccountSynchronisationException( "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); } catch (MailjetException e) { metrics.incrementMailjetError(); - log.error("MAILJETT - Mailjet API error while processing user ID: {}. Error: {}. Continuing with next user.", + log.error("MAILJET - Mailjet API error while processing user ID: {}. Error: {}. Continuing with next user.", userId, e.getMessage(), e); } catch (Exception e) { metrics.incrementUnexpectedError(); - log.error("MAILJETT - Unexpected error processing user ID: {}. Error: {}", + log.error("MAILJET - Unexpected error processing user ID: {}. Error: {}", userId, e.getMessage(), e); // Don't throw - log and continue to avoid blocking all syncs } @@ -131,7 +132,7 @@ private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics // Validate required fields if (accountEmail == null || accountEmail.trim().isEmpty()) { - log.warn("MAILJETT - User ID {} has null or empty email address. Skipping.", userId); + log.warn("MAILJET - User ID {} has null or empty email address. Skipping.", userId); metrics.incrementSkipped(); return; } @@ -148,7 +149,7 @@ private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics // Update the provider_last_updated timestamp on success database.updateProviderLastUpdated(userId); - log.info("MAILJETT - Updated provider_last_updated timestamp for user ID: {}", userId); + log.info("MAILJET - Updated provider_last_updated timestamp for user ID: {}", userId); } /** @@ -164,7 +165,7 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); if (mailjetDetails == null) { - log.warn("MAILJETT - User ID {} has Mailjet ID {} but account not found in Mailjet. Treating as new user.", + log.warn("MAILJET - User ID {} has Mailjet ID {} but account not found in Mailjet. Treating as new user.", userId, mailjetId); // Mailjet account doesn't exist - clear the ID and treat as new database.updateExternalAccount(userId, null); @@ -173,19 +174,19 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan } if (userRecord.isDeleted()) { - log.info("MAILJETT - User ID {} is deleted. Removing from Mailjet.", userId); + log.info("MAILJET - User ID {} is deleted. Removing from Mailjet.", userId); deleteUserFromMailJet(mailjetId, userRecord); metrics.incrementDeleted(); } else if (accountEmailDeliveryFailed) { - log.info("MAILJETT - User ID {} has delivery failed status. Unsubscribing from all lists.", userId); + log.info("MAILJET - User ID {} has delivery failed status. Unsubscribing from all lists.", userId); mailjetApi.updateUserSubscriptions(mailjetId, MailJetSubscriptionAction.REMOVE, MailJetSubscriptionAction.REMOVE); metrics.incrementUnsubscribed(); } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { - log.info("MAILJETT - User ID {} changed email from {} to {}. Recreating Mailjet account.", + log.info("MAILJET - User ID {} changed email from {} to {}. Recreating Mailjet account.", userId, maskEmail(mailjetDetails.getString("Email")), maskEmail(accountEmail)); mailjetApi.permanentlyDeleteAccountById(mailjetId); String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); @@ -198,7 +199,7 @@ private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChan metrics.incrementEmailChanged(); } else { - log.info("MAILJETT - User ID {} has updated details/preferences. Updating Mailjet.", userId); + log.info("MAILJET - User ID {} has updated details/preferences. Updating Mailjet.", userId); updateUserOnMailJet(mailjetId, userRecord); metrics.incrementUpdated(); } @@ -214,13 +215,13 @@ private void handleNewMailjetUser(UserExternalAccountChanges userRecord, Long userId = userRecord.getUserId(); if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { - log.info("MAILJETT - Creating new Mailjet account for user ID {} with email {}", + log.info("MAILJET - Creating new Mailjet account for user ID {} with email {}", userId, maskEmail(accountEmail)); String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); if (mailjetId == null) { - log.error("MAILJETT - Failed to create Mailjet account for user ID {}. Mailjet returned null ID.", userId); + log.error("MAILJET - Failed to create Mailjet account for user ID {}. Mailjet returned null ID.", userId); throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); } @@ -228,7 +229,7 @@ private void handleNewMailjetUser(UserExternalAccountChanges userRecord, metrics.incrementCreated(); } else { - log.info("MAILJETT - User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", + log.info("MAILJET - User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", userId, userRecord.isDeleted(), accountEmailDeliveryFailed); database.updateExternalAccount(userId, null); metrics.incrementSkipped(); @@ -271,7 +272,7 @@ private void updateUserOnMailJet(final String mailjetId, final UserExternalAccou // Store the Mailjet ID in the database database.updateExternalAccount(userId, mailjetId); - log.info("MAILJETT - Updated Mailjet account {} for user ID {} (news={}, events={})", + log.info("MAILJET - Updated Mailjet account {} for user ID {} (news={}, events={})", mailjetId, userId, newsStatus, eventsStatus); } @@ -282,7 +283,7 @@ private void deleteUserFromMailJet(final String mailjetId, final UserExternalAcc throws SegueDatabaseException, MailjetException { if (mailjetId == null || mailjetId.trim().isEmpty()) { - log.warn("MAILJETT - Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); + log.warn("MAILJET - Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); return; } @@ -290,7 +291,7 @@ private void deleteUserFromMailJet(final String mailjetId, final UserExternalAcc mailjetApi.permanentlyDeleteAccountById(mailjetId); database.updateExternalAccount(userId, null); - log.info("MAILJETT - Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); + log.info("MAILJET - Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); } /** @@ -317,22 +318,22 @@ private String maskEmail(String email) { * Log summary of synchronization results. */ private void logSyncSummary(SyncMetrics metrics, int totalUsers) { - log.info("MAILJETT - === Mailjet Synchronization Complete ==="); - log.info("MAILJETT - Total users to process: {}", totalUsers); - log.info("MAILJETT - Successfully processed: {}", metrics.getSuccessCount()); - log.info("MAILJETT - - Created: {}", metrics.getCreatedCount()); - log.info("MAILJETT - - Updated: {}", metrics.getUpdatedCount()); - log.info("MAILJETT - - Deleted: {}", metrics.getDeletedCount()); - log.info("MAILJETT - - Email changed: {}", metrics.getEmailChangedCount()); - log.info("MAILJETT - - Unsubscribed: {}", metrics.getUnsubscribedCount()); - log.info("MAILJETT - - Skipped: {}", metrics.getSkippedCount()); - log.info("MAILJETT - Errors:"); - log.info("MAILJETT - - Database errors: {}", metrics.getDatabaseErrorCount()); - log.info("MAILJETT - - Communication errors: {}", metrics.getCommunicationErrorCount()); - log.info("MAILJETT - - Rate limit errors: {}", metrics.getRateLimitErrorCount()); - log.info("MAILJETT - - Mailjet API errors: {}", metrics.getMailjetErrorCount()); - log.info("MAILJETT - - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); - log.info("MAILJETT - ========================================"); + log.info("MAILJET - === Mailjet Synchronization Complete ==="); + log.info("MAILJET - Total users to process: {}", totalUsers); + log.info("MAILJET - Successfully processed: {}", metrics.getSuccessCount()); + log.info("MAILJET - - Created: {}", metrics.getCreatedCount()); + log.info("MAILJET - - Updated: {}", metrics.getUpdatedCount()); + log.info("MAILJET - - Deleted: {}", metrics.getDeletedCount()); + log.info("MAILJET - - Email changed: {}", metrics.getEmailChangedCount()); + log.info("MAILJET - - Unsubscribed: {}", metrics.getUnsubscribedCount()); + log.info("MAILJET - - Skipped: {}", metrics.getSkippedCount()); + log.info("MAILJET - Errors:"); + log.info("MAILJET - - Database errors: {}", metrics.getDatabaseErrorCount()); + log.info("MAILJET - - Communication errors: {}", metrics.getCommunicationErrorCount()); + log.info("MAILJET - - Rate limit errors: {}", metrics.getRateLimitErrorCount()); + log.info("MAILJET - - Mailjet API errors: {}", metrics.getMailjetErrorCount()); + log.info("MAILJET - - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); + log.info("MAILJET - ========================================"); } /** @@ -352,7 +353,9 @@ private static class SyncMetrics { private int mailjetErrorCount = 0; private int unexpectedErrorCount = 0; - void incrementSuccess() { successCount++; } + void incrementSuccess() { + successCount++; + } void incrementCreated() { createdCount++; } void incrementUpdated() { updatedCount++; } void incrementDeleted() { deletedCount++; } diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index 8e7a30025f..f9e1dac0f8 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -74,7 +74,7 @@ public List getRecentlyChangedRecords() throws Segue try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { - log.debug("MAILJETT - Executing query to fetch recently changed user records"); + log.debug("MAILJET - Executing query to fetch recently changed user records"); try (ResultSet results = pst.executeQuery()) { List listOfResults = new ArrayList<>(); @@ -86,17 +86,17 @@ public List getRecentlyChangedRecords() throws Segue } catch (SQLException | JSONException e) { // Log but continue processing other users long userId = results.getLong("id"); - log.error("MAILJETT - Error building UserExternalAccountChanges for user ID: {}. Error: {}", + log.error("MAILJET - Error building UserExternalAccountChanges for user ID: {}. Error: {}", userId, e.getMessage(), e); } } - log.debug("MAILJETT - Retrieved {} user records requiring synchronization", listOfResults.size()); + log.debug("MAILJET - Retrieved {} user records requiring synchronization", listOfResults.size()); return listOfResults; } } catch (SQLException e) { - log.error("MAILJETT - Database error while fetching recently changed records", e); + log.error("MAILJET - Database error while fetching recently changed records", e); throw new SegueDatabaseException("Failed to retrieve recently changed user records", e); } } @@ -121,14 +121,14 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc int rowsUpdated = pst.executeUpdate(); if (rowsUpdated == 0) { - log.warn("MAILJETT - No rows updated when setting provider_last_updated for user ID: {}. " + log.warn("MAILJET - No rows updated when setting provider_last_updated for user ID: {}. " + "User may not have an external_accounts record yet.", userId); } else { - log.debug("MAILJETT - Updated provider_last_updated for user ID: {}", userId); + log.debug("MAILJET - Updated provider_last_updated for user ID: {}", userId); } } catch (SQLException e) { - log.error("MAILJETT - Database error updating provider_last_updated for user ID: {}", userId, e); + log.error("MAILJET - Database error updating provider_last_updated for user ID: {}", userId, e); throw new SegueDatabaseException("Failed to update provider_last_updated for user: " + userId, e); } } @@ -156,14 +156,14 @@ public void updateExternalAccount(final Long userId, final String providerUserId int rowsAffected = pst.executeUpdate(); if (rowsAffected > 0) { - log.debug("MAILJETT - Upserted external_account for user ID: {} with Mailjet ID: {}", + log.debug("MAILJET - Upserted external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { - log.warn("MAILJETT - Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); + log.warn("MAILJET - Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); } } catch (SQLException e) { - log.error("MAILJETT - Database error upserting external_account for user ID: {} with Mailjet ID: {}", + log.error("MAILJET - Database error upserting external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier, e); throw new SegueDatabaseException( "Failed to upsert external_account for user: " + userId, e); @@ -220,12 +220,12 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, if (wasNull) { // User has no preference set - treat as null (not subscribed) - log.debug("MAILJETT - User ID {} has NULL preference for {}. Treating as not subscribed.", + log.debug("MAILJET - User ID {} has NULL preference for {}. Treating as not subscribed.", userId, preferenceName); return null; } - log.debug("MAILJETT - User ID {} has preference {} = {}", userId, preferenceName, value); + log.debug("MAILJET - User ID {} has preference {} = {}", userId, preferenceName, value); return value; } @@ -241,7 +241,7 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, */ private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { - log.debug("MAILJETT - User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); + log.debug("MAILJET - User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -249,7 +249,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // Check for empty JSON array if ("[]".equals(trimmed) || "null".equals(trimmed)) { - log.debug("MAILJETT - User ID {} has empty/null registered_contexts. Stage: unknown", userId); + log.debug("MAILJET - User ID {} has empty/null registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -257,8 +257,8 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // Parse as JSONArray (array_to_json returns proper JSON array) JSONArray array = new JSONArray(trimmed); - if (array.length() == 0) { - log.debug("MAILJETT - User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); + if (array.isEmpty()) { + log.debug("MAILJET - User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); return "unknown"; } @@ -270,7 +270,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); - log.debug("MAILJETT - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + log.debug("MAILJET - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", userId, stage, i, normalized); return normalized; } @@ -278,17 +278,17 @@ private String extractStageFromRegisteredContexts(Long userId, String registered } // No 'stage' key found, use fallback pattern matching - String fallbackStage = fallbackStageDetection(userId, trimmed); + String fallbackStage = fallbackStageDetection(trimmed); if (!"unknown".equals(fallbackStage)) { - log.debug("MAILJETT - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + log.debug("MAILJET - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); } else { - log.warn("MAILJETT - User ID {} has registered_contexts but no 'stage' key found: {}. Stage: unknown", + log.warn("MAILJET - User ID {} has registered_contexts but no 'stage' key found: {}. Stage: unknown", userId, truncateForLog(trimmed)); } return fallbackStage; } catch (JSONException e) { - log.warn("MAILJETT - User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", + log.warn("MAILJET - User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", userId, truncateForLog(registeredContextsJson), e.getMessage()); return "unknown"; } @@ -298,7 +298,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered * Fallback stage detection by pattern matching in the JSON string. * Used when no explicit 'stage' key is found. */ - private String fallbackStageDetection(Long userId, String jsonString) { + private String fallbackStageDetection(String jsonString) { String lower = jsonString.toLowerCase(); boolean hasGcse = lower.contains("gcse"); boolean hasALevel = lower.contains("a_level") || lower.contains("alevel") || lower.contains("a level"); @@ -342,7 +342,7 @@ private String normalizeStage(String stage) { return "ALL"; default: // Warn about unexpected stage values - log.warn("MAILJETT - Unexpected stage value '{}' encountered. Returning 'unknown'. " + log.warn("MAILJET - Unexpected stage value '{}' encountered. Returning 'unknown'. " + "Expected values: gcse, a_level, gcse_and_a_level, both", stage); return "unknown"; } diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index 42b046512f..69d0a30450 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -94,10 +94,6 @@ public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetA this.eventsListId = mailjetEventsListId; this.legalListId = mailjetLegalListId; this.rateLimitDelayMs = rateLimitDelayMs; - - log.info("MAILJETT - MailJet API wrapper initialized with list IDs - News: {}, Events: {}, Legal: {}", - newsListId, eventsListId, legalListId); - log.info("MAILJETT - Rate limiting enabled: {}ms delay between API calls", rateLimitDelayMs); } /** @@ -109,38 +105,35 @@ public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetA */ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { - log.info("MAILJETT - Attempted to get account with null/empty identifier"); + log.info("MAILJET - Attempted to get account with null/empty identifier"); return null; } waitForRateLimit(); // Apply rate limiting try { - log.info("MAILJETT - Fetching Mailjet account: {}", mailjetIdOrEmail); + log.info("MAILJET - Fetching Mailjet account: {}", mailjetIdOrEmail); MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); - log.info("MAILJETT - request: {}", request.getBody()); MailjetResponse response = mailjetClient.get(request); - log.info("MAILJETT - response: {}", response.getRawResponseContent()); - if (response.getStatus() == 404) { - log.info("MAILJETT - Mailjet account not found: {}", mailjetIdOrEmail); + log.info("MAILJET - Mailjet account not found: {}", mailjetIdOrEmail); return null; } if (response.getStatus() != 200) { - log.warn("MAILJETT - Unexpected Mailjet response status {} when fetching account: {}", + log.warn("MAILJET - Unexpected Mailjet response status {} when fetching account: {}", response.getStatus(), mailjetIdOrEmail); throw new MailjetException("Unexpected response status: " + response.getStatus()); } JSONArray responseData = response.getData(); if (response.getTotal() == 1 && !responseData.isEmpty()) { - log.info("MAILJETT - Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); + log.info("MAILJET - Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); return responseData.getJSONObject(0); } - log.info("MAILJETT - Mailjet account not found (total={}): {}", response.getTotal(), mailjetIdOrEmail); + log.info("MAILJET - Mailjet account not found (total={}): {}", response.getTotal(), mailjetIdOrEmail); return null; } catch (MailjetException e) { @@ -149,7 +142,7 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma (e.getMessage().contains("404") || e.getMessage().toLowerCase().contains("not found") || e.getMessage().toLowerCase().contains("object not found"))) { - log.info("MAILJETT - Mailjet account not found (404): {}. Error: {}", mailjetIdOrEmail, e.getMessage()); + log.info("MAILJET - Mailjet account not found (404): {}. Error: {}", mailjetIdOrEmail, e.getMessage()); return null; // Treat 404 as "not found", not an error } @@ -157,11 +150,11 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJETT - Communication error fetching Mailjet account: {}", mailjetIdOrEmail, e); + log.error("MAILJET - Communication error fetching Mailjet account: {}", mailjetIdOrEmail, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("MAILJETT - Error fetching Mailjet account: {}", mailjetIdOrEmail, e); + log.error("MAILJET - Error fetching Mailjet account: {}", mailjetIdOrEmail, e); throw e; } } @@ -180,23 +173,19 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE waitForRateLimit(); // Apply rate limiting try { - log.info("MAILJETT - Deleting Mailjet account: {}", mailjetId); + log.info("MAILJET - Deleting Mailjet account: {}", mailjetId); MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); - log.info("MAILJETT - request: {}", request.getBody()); - MailjetResponse response = mailjetClient.delete(request); - log.info("MAILJETT - response: {}", response.getRawResponseContent()); - if (response.getStatus() == 204 || response.getStatus() == 200) { - log.info("MAILJETT - Successfully deleted Mailjet account: {}", mailjetId); + log.info("MAILJET - Successfully deleted Mailjet account: {}", mailjetId); } else if (response.getStatus() == 404) { - log.warn("MAILJETT - Attempted to delete non-existent Mailjet account: {}", mailjetId); + log.warn("MAILJET - Attempted to delete non-existent Mailjet account: {}", mailjetId); // Don't throw - account is already gone } else { - log.error("MAILJETT - Unexpected response status {} when deleting Mailjet account: {}", + log.error("MAILJET - Unexpected response status {} when deleting Mailjet account: {}", response.getStatus(), mailjetId); throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); } @@ -207,7 +196,7 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE (e.getMessage().contains("404") || e.getMessage().toLowerCase().contains("not found") || e.getMessage().toLowerCase().contains("object not found"))) { - log.warn("MAILJETT - Mailjet account already deleted or not found: {}. Treating as success.", mailjetId); + log.warn("MAILJET - Mailjet account already deleted or not found: {}. Treating as success.", mailjetId); return; // Already deleted - treat as success } @@ -215,10 +204,10 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJETT - Communication error deleting Mailjet account: {}", mailjetId, e); + log.error("MAILJET - Communication error deleting Mailjet account: {}", mailjetId, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("MAILJETT - Error deleting Mailjet account: {}", mailjetId, e); + log.error("MAILJET - Error deleting Mailjet account: {}", mailjetId, e); throw e; } } @@ -234,7 +223,7 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE */ public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { if (email == null || email.trim().isEmpty()) { - log.warn("MAILJETT - Attempted to create Mailjet account with null/empty email"); + log.warn("MAILJET - Attempted to create Mailjet account with null/empty email"); return null; } @@ -243,53 +232,49 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce waitForRateLimit(); // Apply rate limiting try { - log.info("MAILJETT - Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); + log.info("MAILJET - Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); MailjetRequest request = new MailjetRequest(Contact.resource) .property(Contact.EMAIL, normalizedEmail); - log.info("MAILJETT - request: {}", request.getBody()); MailjetResponse response = mailjetClient.post(request); - log.info("MAILJETT - response: {}", response.getRawResponseContent()); - if (response.getStatus() == 201 || response.getStatus() == 200) { JSONObject responseData = response.getData().getJSONObject(0); - log.info("MAILJETT - responseData : {}", responseData.toString()); String mailjetId = String.valueOf(responseData.get("ID")); - log.info("MAILJETT - Successfully created Mailjet account {} for email: {}", + log.info("MAILJET - Successfully created Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; } - log.error("MAILJETT - Unexpected response status {} when creating Mailjet account for: {}", + log.error("MAILJET - Unexpected response status {} when creating Mailjet account for: {}", response.getStatus(), maskEmail(normalizedEmail)); throw new MailjetException("Failed to create account. Status: " + response.getStatus()); } catch (MailjetClientRequestException e) { // Check if user already exists if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { - log.info("MAILJETT - User already exists in Mailjet for email: {}. Fetching existing account.", + log.info("MAILJET - User already exists in Mailjet for email: {}. Fetching existing account.", maskEmail(normalizedEmail)); try { JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); if (existingAccount != null) { String mailjetId = String.valueOf(existingAccount.get("ID")); - log.info("MAILJETT - Retrieved existing Mailjet account {} for email: {}", + log.info("MAILJET - Retrieved existing Mailjet account {} for email: {}", mailjetId, maskEmail(normalizedEmail)); return mailjetId; } else { - log.error("MAILJETT - User reported as existing but couldn't fetch account for: {}", + log.error("MAILJET - User reported as existing but couldn't fetch account for: {}", maskEmail(normalizedEmail)); throw new MailjetException("Account exists but couldn't be retrieved"); } } catch (JSONException je) { - log.error("MAILJETT - JSON parsing error when retrieving existing account for: {}", + log.error("MAILJET - JSON parsing error when retrieving existing account for: {}", maskEmail(normalizedEmail), je); throw new MailjetException("Failed to parse existing account data", je); } } else { - log.error("MAILJETT - Failed to create Mailjet account for: {}. Error: {}", + log.error("MAILJET - Failed to create Mailjet account for: {}. Error: {}", maskEmail(normalizedEmail), e.getMessage(), e); throw new MailjetException("Failed to create account: " + e.getMessage(), e); } @@ -299,14 +284,14 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJETT - Communication error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); + log.error("MAILJET - Communication error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("MAILJETT - Error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); + log.error("MAILJET - Error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); throw e; } catch (JSONException e) { - log.error("MAILJETT - JSON parsing error when creating account for: {}", maskEmail(normalizedEmail), e); + log.error("MAILJET - JSON parsing error when creating account for: {}", maskEmail(normalizedEmail), e); throw new MailjetException("Failed to parse Mailjet response", e); } } @@ -331,7 +316,7 @@ public void updateUserProperties(final String mailjetId, final String firstName, waitForRateLimit(); // Apply rate limiting try { - log.info("MAILJETT - Updating properties for Mailjet account: {} (role={}, stage={}, status={})", + log.info("MAILJET - Updating properties for Mailjet account: {} (role={}, stage={}, status={})", mailjetId, role, stage, emailVerificationStatus); MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId) @@ -343,16 +328,12 @@ public void updateUserProperties(final String mailjetId, final String firstName, .put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown")) ); - log.info("MAILJETT - request: {}", request.getBody()); - MailjetResponse response = mailjetClient.put(request); - log.info("MAILJETT - response: {}", response.getRawResponseContent()); - if (response.getStatus() == 200 && response.getTotal() == 1) { - log.info("MAILJETT - Successfully updated properties for Mailjet account: {}", mailjetId); + log.info("MAILJET - Successfully updated properties for Mailjet account: {}", mailjetId); } else { - log.error("MAILJETT - Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", + log.error("MAILJET - Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); throw new MailjetException( String.format("Failed to update user properties. Status: %d, Total: %d", @@ -365,7 +346,7 @@ public void updateUserProperties(final String mailjetId, final String firstName, (e.getMessage().contains("404") || e.getMessage().toLowerCase().contains("not found") || e.getMessage().toLowerCase().contains("object not found"))) { - log.error("MAILJETT - Mailjet contact not found when updating properties: {}. The contact may have been deleted.", mailjetId); + log.error("MAILJET - Mailjet contact not found when updating properties: {}. The contact may have been deleted.", mailjetId); throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); } @@ -373,10 +354,10 @@ public void updateUserProperties(final String mailjetId, final String firstName, if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJETT - Communication error updating properties for Mailjet account: {}", mailjetId, e); + log.error("MAILJET - Communication error updating properties for Mailjet account: {}", mailjetId, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("MAILJETT - Error updating properties for Mailjet account: {}", mailjetId, e); + log.error("MAILJET - Error updating properties for Mailjet account: {}", mailjetId, e); throw e; } } @@ -405,7 +386,7 @@ public void updateUserSubscriptions(final String mailjetId, waitForRateLimit(); // Apply rate limiting try { - log.info("MAILJETT - Updating subscriptions for Mailjet account: {} (news={}, events={})", + log.info("MAILJET - Updating subscriptions for Mailjet account: {} (news={}, events={})", mailjetId, newsEmails, eventsEmails); MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId) @@ -421,16 +402,12 @@ public void updateUserSubscriptions(final String mailjetId, .put(ContactslistImportList.ACTION, eventsEmails.getValue())) ); - log.info("MAILJETT - request: {}", request.getBody()); - MailjetResponse response = mailjetClient.post(request); - log.info("MAILJETT - response: {}", response.getRawResponseContent()); - if (response.getStatus() == 201 && response.getTotal() == 1) { - log.info("MAILJETT - Successfully updated subscriptions for Mailjet account: {}", mailjetId); + log.info("MAILJET - Successfully updated subscriptions for Mailjet account: {}", mailjetId); } else { - log.error("MAILJETT - Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", + log.error("MAILJET - Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); throw new MailjetException( String.format("Failed to update user subscriptions. Status: %d, Total: %d", @@ -443,7 +420,7 @@ public void updateUserSubscriptions(final String mailjetId, (e.getMessage().contains("404") || e.getMessage().toLowerCase().contains("not found") || e.getMessage().toLowerCase().contains("object not found"))) { - log.error("MAILJETT - Mailjet contact not found when updating subscriptions: {}. The contact may have been deleted.", mailjetId); + log.error("MAILJET - Mailjet contact not found when updating subscriptions: {}. The contact may have been deleted.", mailjetId); throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); } @@ -451,10 +428,10 @@ public void updateUserSubscriptions(final String mailjetId, if (e.getMessage() != null && (e.getMessage().toLowerCase().contains("timeout") || e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJETT - Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); + log.error("MAILJET - Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); } - log.error("MAILJETT - Error updating subscriptions for Mailjet account: {}", mailjetId, e); + log.error("MAILJET - Error updating subscriptions for Mailjet account: {}", mailjetId, e); throw e; } } @@ -492,12 +469,12 @@ private synchronized void waitForRateLimit() { if (timeSinceLastCall < rateLimitDelayMs && lastApiCallTime > 0) { long waitTime = rateLimitDelayMs - timeSinceLastCall; - log.info("MAILJETT - Rate limiting: waiting {}ms before next API call", waitTime); + log.info("MAILJET - Rate limiting: waiting {}ms before next API call", waitTime); try { Thread.sleep(waitTime); } catch (InterruptedException e) { - log.warn("MAILJETT - Rate limit wait interrupted", e); + log.warn("MAILJET - Rate limit wait interrupted", e); Thread.currentThread().interrupt(); } } From 93b42c2d9455d39088abb36aeacf3164ec004089 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 11:20:53 +0200 Subject: [PATCH 11/22] PATCH 18 --- .../api/managers/ExternalAccountManager.java | 661 +++++++++-------- .../SegueGuiceConfigurationModule.java | 2 +- .../util/email/MailJetApiClientWrapper.java | 694 +++++++----------- 3 files changed, 618 insertions(+), 739 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index 949c7c2877..af48b5f1fb 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -31,354 +31,379 @@ import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; public class ExternalAccountManager implements IExternalAccountManager { - private static final Logger log = LoggerFactory.getLogger(ExternalAccountManager.class); - - private final IExternalAccountDataManager database; - private final MailJetApiClientWrapper mailjetApi; - - /** - * Synchronise account settings, email preferences and verification status with third party providers. - *
- * Currently this class is highly specialised for synchronising with MailJet. - * - * @param mailjetApi - to enable updates on MailJet - * @param database - to persist external identifiers and to record sync success. - */ - public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IExternalAccountDataManager database) { - this.database = database; - this.mailjetApi = mailjetApi; - } - - /** - * Synchronise account settings and data with external providers. - *
- * Whilst the actions this method takes are mostly idempotent, it should not be run simultaneously with itself. - * - * @throws ExternalAccountSynchronisationException on unrecoverable errors with external providers. - */ - @Override - public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { - log.info("MAILJET - Starting Mailjet synchronization process"); - - List userRecordsToUpdate; - try { - userRecordsToUpdate = database.getRecentlyChangedRecords(); - log.info("MAILJET - Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); - } catch (SegueDatabaseException e) { - log.error("MAILJET - Database error whilst collecting users whose details have changed!", e); - throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); + private static final Logger log = LoggerFactory.getLogger(ExternalAccountManager.class); + + private final IExternalAccountDataManager database; + private final MailJetApiClientWrapper mailjetApi; + + /** + * Synchronise account settings, email preferences and verification status with third party providers. + *
+ * Currently this class is highly specialised for synchronising with MailJet. + * + * @param mailjetApi - to enable updates on MailJet + * @param database - to persist external identifiers and to record sync success. + */ + public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IExternalAccountDataManager database) { + this.database = database; + this.mailjetApi = mailjetApi; } - if (userRecordsToUpdate.isEmpty()) { - log.info("MAILJET - No users to synchronize. Exiting."); - return; + /** + * Synchronise account settings and data with external providers. + *
+ * Whilst the actions this method takes are mostly idempotent, it should not be run simultaneously with itself. + * + * @throws ExternalAccountSynchronisationException on unrecoverable errors with external providers. + */ + @Override + public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { + log.info("Starting Mailjet synchronization process"); + + List userRecordsToUpdate; + try { + userRecordsToUpdate = database.getRecentlyChangedRecords(); + log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); + } catch (SegueDatabaseException e) { + log.error("Database error whilst collecting users whose details have changed", e); + throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); + } + + if (userRecordsToUpdate.isEmpty()) { + log.info("No users to synchronize"); + return; + } + + SyncMetrics metrics = new SyncMetrics(); + + for (UserExternalAccountChanges userRecord : userRecordsToUpdate) { + Long userId = userRecord.getUserId(); + + try { + processUserSync(userRecord, metrics); + metrics.incrementSuccess(); + + } catch (SegueDatabaseException e) { + metrics.incrementDatabaseError(); + log.error("Database error storing Mailjet update for user ID: {}", userId, e); + // Continue processing other users + + } catch (MailjetClientCommunicationException e) { + metrics.incrementCommunicationError(); + log.error("Failed to communicate with Mailjet while processing user ID: {}", userId, e); + throw new ExternalAccountSynchronisationException("Failed to connect to Mailjet" + e); + + } catch (MailjetRateLimitException e) { + metrics.incrementRateLimitError(); + log.warn("Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit", + userId, metrics.getSuccessCount()); + throw new ExternalAccountSynchronisationException( + "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); + + } catch (MailjetException e) { + metrics.incrementMailjetError(); + log.error("Mailjet API error while processing user ID: {}. Continuing with next user", userId, e); + + } catch (Exception e) { + metrics.incrementUnexpectedError(); + log.error("Unexpected error processing user ID: {}", userId, e); + } + } + + logSyncSummary(metrics, userRecordsToUpdate.size()); } - SyncMetrics metrics = new SyncMetrics(); - - for (UserExternalAccountChanges userRecord : userRecordsToUpdate) { - Long userId = userRecord.getUserId(); - - try { - log.info("MAILJET - Processing user ID: {} with email: {}", userId, maskEmail(userRecord.getAccountEmail())); - processUserSync(userRecord, metrics); - metrics.incrementSuccess(); - log.info("MAILJET - Successfully processed user ID: {}", userId); - - } catch (SegueDatabaseException e) { - metrics.incrementDatabaseError(); - log.error("MAILJET - Database error storing Mailjet update for user ID: {}. Error: {}", - userId, e.getMessage(), e); - // Continue processing other users - database errors shouldn't stop the entire sync - - } catch (MailjetClientCommunicationException e) { - metrics.incrementCommunicationError(); - log.error("MAILJET - Failed to communicate with Mailjet while processing user ID: {}. Error: {}", - userId, e.getMessage(), e); - throw new ExternalAccountSynchronisationException( - "Failed to successfully connect to Mailjet" + e); - - } catch (MailjetRateLimitException e) { - metrics.incrementRateLimitError(); - log.warn("MAILJET - Mailjet rate limit exceeded while processing user ID: {}. " + - "Processed {} users before limit.", - userId, metrics.getSuccessCount()); - throw new ExternalAccountSynchronisationException( - "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); - - } catch (MailjetException e) { - metrics.incrementMailjetError(); - log.error("MAILJET - Mailjet API error while processing user ID: {}. Error: {}. Continuing with next user.", - userId, e.getMessage(), e); - } catch (Exception e) { - metrics.incrementUnexpectedError(); - log.error("MAILJET - Unexpected error processing user ID: {}. Error: {}", - userId, e.getMessage(), e); - // Don't throw - log and continue to avoid blocking all syncs - } - } - - logSyncSummary(metrics, userRecordsToUpdate.size()); - } - - /** - * Process synchronization for a single user. - */ - private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics metrics) - throws SegueDatabaseException, MailjetException { + /** + * Process synchronization for a single user. + */ + private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { - Long userId = userRecord.getUserId(); - String accountEmail = userRecord.getAccountEmail(); + Long userId = userRecord.getUserId(); + String accountEmail = userRecord.getAccountEmail(); - // Validate required fields - if (accountEmail == null || accountEmail.trim().isEmpty()) { - log.warn("MAILJET - User ID {} has null or empty email address. Skipping.", userId); - metrics.incrementSkipped(); - return; - } + if (accountEmail == null || accountEmail.trim().isEmpty()) { + log.warn("User ID {} has null or empty email address. Skipping", userId); + metrics.incrementSkipped(); + return; + } - boolean accountEmailDeliveryFailed = - EmailVerificationStatus.DELIVERY_FAILED.equals(userRecord.getEmailVerificationStatus()); - String mailjetId = userRecord.getProviderUserId(); + boolean accountEmailDeliveryFailed = + EmailVerificationStatus.DELIVERY_FAILED.equals(userRecord.getEmailVerificationStatus()); + String mailjetId = userRecord.getProviderUserId(); - if (mailjetId != null && !mailjetId.trim().isEmpty()) { - handleExistingMailjetUser(mailjetId, userRecord, accountEmail, accountEmailDeliveryFailed, metrics); - } else { - handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); - } + if (mailjetId != null && !mailjetId.trim().isEmpty()) { + handleExistingMailjetUser(mailjetId, userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + } else { + handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + } - // Update the provider_last_updated timestamp on success - database.updateProviderLastUpdated(userId); - log.info("MAILJET - Updated provider_last_updated timestamp for user ID: {}", userId); - } - - /** - * Handle synchronization for users that already exist in Mailjet. - */ - private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChanges userRecord, - String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) - throws SegueDatabaseException, MailjetException { - - Long userId = userRecord.getUserId(); - - // Fetch current Mailjet details - JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); - - if (mailjetDetails == null) { - log.warn("MAILJET - User ID {} has Mailjet ID {} but account not found in Mailjet. Treating as new user.", - userId, mailjetId); - // Mailjet account doesn't exist - clear the ID and treat as new - database.updateExternalAccount(userId, null); - handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); - return; + database.updateProviderLastUpdated(userId); } - if (userRecord.isDeleted()) { - log.info("MAILJET - User ID {} is deleted. Removing from Mailjet.", userId); - deleteUserFromMailJet(mailjetId, userRecord); - metrics.incrementDeleted(); - - } else if (accountEmailDeliveryFailed) { - log.info("MAILJET - User ID {} has delivery failed status. Unsubscribing from all lists.", userId); - mailjetApi.updateUserSubscriptions(mailjetId, - MailJetSubscriptionAction.REMOVE, - MailJetSubscriptionAction.REMOVE); - metrics.incrementUnsubscribed(); - - } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { - log.info("MAILJET - User ID {} changed email from {} to {}. Recreating Mailjet account.", - userId, maskEmail(mailjetDetails.getString("Email")), maskEmail(accountEmail)); - mailjetApi.permanentlyDeleteAccountById(mailjetId); - String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); - - if (newMailjetId == null) { - throw new MailjetException("Failed to create new Mailjet account after email change for user: " + userId); - } - - updateUserOnMailJet(newMailjetId, userRecord); - metrics.incrementEmailChanged(); - - } else { - log.info("MAILJET - User ID {} has updated details/preferences. Updating Mailjet.", userId); - updateUserOnMailJet(mailjetId, userRecord); - metrics.incrementUpdated(); + /** + * Handle synchronization for users that already exist in Mailjet. + */ + private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChanges userRecord, + String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); + + if (mailjetDetails == null) { + log.warn("User ID {} has Mailjet ID {} but account not found. Treating as new user", userId, mailjetId); + database.updateExternalAccount(userId, null); + handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + return; + } + + if (userRecord.isDeleted()) { + deleteUserFromMailJet(mailjetId, userRecord); + metrics.incrementDeleted(); + + } else if (accountEmailDeliveryFailed) { + log.info("User ID {} has delivery failed status. Unsubscribing from all lists", userId); + mailjetApi.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.REMOVE, + MailJetSubscriptionAction.REMOVE); + metrics.incrementUnsubscribed(); + + } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { + log.info("User ID {} changed email. Recreating Mailjet account", userId); + mailjetApi.permanentlyDeleteAccountById(mailjetId); + String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + + if (newMailjetId == null) { + throw new MailjetException("Failed to create new Mailjet account after email change for user: " + userId); + } + + updateUserOnMailJet(newMailjetId, userRecord); + metrics.incrementEmailChanged(); + + } else { + updateUserOnMailJet(mailjetId, userRecord); + metrics.incrementUpdated(); + } } - } - /** - * Handle synchronization for users that don't exist in Mailjet yet. - */ - private void handleNewMailjetUser(UserExternalAccountChanges userRecord, - String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) - throws SegueDatabaseException, MailjetException { + /** + * Handle synchronization for users that don't exist in Mailjet yet. + */ + private void handleNewMailjetUser(UserExternalAccountChanges userRecord, + String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { - Long userId = userRecord.getUserId(); + Long userId = userRecord.getUserId(); - if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { - log.info("MAILJET - Creating new Mailjet account for user ID {} with email {}", - userId, maskEmail(accountEmail)); + if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { + log.info("Creating new Mailjet account for user ID {}", userId); - String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); - if (mailjetId == null) { - log.error("MAILJET - Failed to create Mailjet account for user ID {}. Mailjet returned null ID.", userId); - throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); - } + if (mailjetId == null) { + log.error("Failed to create Mailjet account for user ID {}. Mailjet returned null ID", userId); + throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); + } - updateUserOnMailJet(mailjetId, userRecord); - metrics.incrementCreated(); + updateUserOnMailJet(mailjetId, userRecord); + metrics.incrementCreated(); - } else { - log.info("MAILJET - User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping.", - userId, userRecord.isDeleted(), accountEmailDeliveryFailed); - database.updateExternalAccount(userId, null); - metrics.incrementSkipped(); + } else { + log.debug("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping", + userId, userRecord.isDeleted(), accountEmailDeliveryFailed); + database.updateExternalAccount(userId, null); + metrics.incrementSkipped(); + } } - } - /** - * Update user details and subscriptions in Mailjet. - */ - private void updateUserOnMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) - throws SegueDatabaseException, MailjetException { + /** + * Update user details and subscriptions in Mailjet. + */ + private void updateUserOnMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) + throws SegueDatabaseException, MailjetException { - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); - } + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } - Long userId = userRecord.getUserId(); - - // Update user properties - String stage = userRecord.getStage() != null ? userRecord.getStage() : "unknown"; - mailjetApi.updateUserProperties( - mailjetId, - userRecord.getGivenName(), - userRecord.getRole().toString(), - userRecord.getEmailVerificationStatus().toString(), - stage - ); - - // Update subscriptions - MailJetSubscriptionAction newsStatus = Boolean.TRUE.equals(userRecord.allowsNewsEmails()) - ? MailJetSubscriptionAction.FORCE_SUBSCRIBE - : MailJetSubscriptionAction.UNSUBSCRIBE; - - MailJetSubscriptionAction eventsStatus = Boolean.TRUE.equals(userRecord.allowsEventsEmails()) - ? MailJetSubscriptionAction.FORCE_SUBSCRIBE - : MailJetSubscriptionAction.UNSUBSCRIBE; - - mailjetApi.updateUserSubscriptions(mailjetId, newsStatus, eventsStatus); - - // Store the Mailjet ID in the database - database.updateExternalAccount(userId, mailjetId); - - log.info("MAILJET - Updated Mailjet account {} for user ID {} (news={}, events={})", - mailjetId, userId, newsStatus, eventsStatus); - } - - /** - * Delete user from Mailjet (GDPR compliance). - */ - private void deleteUserFromMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) - throws SegueDatabaseException, MailjetException { - - if (mailjetId == null || mailjetId.trim().isEmpty()) { - log.warn("MAILJET - Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); - return; + Long userId = userRecord.getUserId(); + String stage = userRecord.getStage() != null ? userRecord.getStage() : "unknown"; + + mailjetApi.updateUserProperties( + mailjetId, + userRecord.getGivenName(), + userRecord.getRole().toString(), + userRecord.getEmailVerificationStatus().toString(), + stage + ); + + MailJetSubscriptionAction newsStatus = Boolean.TRUE.equals(userRecord.allowsNewsEmails()) + ? MailJetSubscriptionAction.FORCE_SUBSCRIBE + : MailJetSubscriptionAction.UNSUBSCRIBE; + + MailJetSubscriptionAction eventsStatus = Boolean.TRUE.equals(userRecord.allowsEventsEmails()) + ? MailJetSubscriptionAction.FORCE_SUBSCRIBE + : MailJetSubscriptionAction.UNSUBSCRIBE; + + mailjetApi.updateUserSubscriptions(mailjetId, newsStatus, eventsStatus); + database.updateExternalAccount(userId, mailjetId); + + log.debug("Updated Mailjet account {} for user ID {} (news={}, events={})", + mailjetId, userId, newsStatus, eventsStatus); } - Long userId = userRecord.getUserId(); - mailjetApi.permanentlyDeleteAccountById(mailjetId); - database.updateExternalAccount(userId, null); + /** + * Delete user from Mailjet (GDPR compliance). + */ + private void deleteUserFromMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) + throws SegueDatabaseException, MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + log.warn("Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); + return; + } - log.info("MAILJET - Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); - } + Long userId = userRecord.getUserId(); + mailjetApi.permanentlyDeleteAccountById(mailjetId); + database.updateExternalAccount(userId, null); - /** - * Mask email address for logging (show only first 3 chars and domain). - */ - private String maskEmail(String email) { - if (email == null || email.isEmpty()) { - return "[empty]"; + log.info("Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); } - int atIndex = email.indexOf('@'); - if (atIndex <= 0) { - return email.substring(0, Math.min(3, email.length())) + "***"; + /** + * Log summary of synchronization results. + */ + private void logSyncSummary(SyncMetrics metrics, int totalUsers) { + log.info("=== Mailjet Synchronization Complete ==="); + log.info("Total users to process: {}", totalUsers); + log.info("Successfully processed: {}", metrics.getSuccessCount()); + log.info(" - Created: {}", metrics.getCreatedCount()); + log.info(" - Updated: {}", metrics.getUpdatedCount()); + log.info(" - Deleted: {}", metrics.getDeletedCount()); + log.info(" - Email changed: {}", metrics.getEmailChangedCount()); + log.info(" - Unsubscribed: {}", metrics.getUnsubscribedCount()); + log.info(" - Skipped: {}", metrics.getSkippedCount()); + log.info("Errors:"); + log.info(" - Database errors: {}", metrics.getDatabaseErrorCount()); + log.info(" - Communication errors: {}", metrics.getCommunicationErrorCount()); + log.info(" - Rate limit errors: {}", metrics.getRateLimitErrorCount()); + log.info(" - Mailjet API errors: {}", metrics.getMailjetErrorCount()); + log.info(" - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); + log.info("========================================"); } - String localPart = email.substring(0, atIndex); - String domain = email.substring(atIndex); - String masked = localPart.substring(0, Math.min(3, localPart.length())) + "***"; - - return masked + domain; - } - - /** - * Log summary of synchronization results. - */ - private void logSyncSummary(SyncMetrics metrics, int totalUsers) { - log.info("MAILJET - === Mailjet Synchronization Complete ==="); - log.info("MAILJET - Total users to process: {}", totalUsers); - log.info("MAILJET - Successfully processed: {}", metrics.getSuccessCount()); - log.info("MAILJET - - Created: {}", metrics.getCreatedCount()); - log.info("MAILJET - - Updated: {}", metrics.getUpdatedCount()); - log.info("MAILJET - - Deleted: {}", metrics.getDeletedCount()); - log.info("MAILJET - - Email changed: {}", metrics.getEmailChangedCount()); - log.info("MAILJET - - Unsubscribed: {}", metrics.getUnsubscribedCount()); - log.info("MAILJET - - Skipped: {}", metrics.getSkippedCount()); - log.info("MAILJET - Errors:"); - log.info("MAILJET - - Database errors: {}", metrics.getDatabaseErrorCount()); - log.info("MAILJET - - Communication errors: {}", metrics.getCommunicationErrorCount()); - log.info("MAILJET - - Rate limit errors: {}", metrics.getRateLimitErrorCount()); - log.info("MAILJET - - Mailjet API errors: {}", metrics.getMailjetErrorCount()); - log.info("MAILJET - - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); - log.info("MAILJET - ========================================"); - } - - /** - * Inner class to track synchronization metrics. - */ - private static class SyncMetrics { - private int successCount = 0; - private int createdCount = 0; - private int updatedCount = 0; - private int deletedCount = 0; - private int emailChangedCount = 0; - private int unsubscribedCount = 0; - private int skippedCount = 0; - private int databaseErrorCount = 0; - private int communicationErrorCount = 0; - private int rateLimitErrorCount = 0; - private int mailjetErrorCount = 0; - private int unexpectedErrorCount = 0; - - void incrementSuccess() { - successCount++; + /** + * Inner class to track synchronization metrics. + */ + private static class SyncMetrics { + private int successCount = 0; + private int createdCount = 0; + private int updatedCount = 0; + private int deletedCount = 0; + private int emailChangedCount = 0; + private int unsubscribedCount = 0; + private int skippedCount = 0; + private int databaseErrorCount = 0; + private int communicationErrorCount = 0; + private int rateLimitErrorCount = 0; + private int mailjetErrorCount = 0; + private int unexpectedErrorCount = 0; + + void incrementSuccess() { + successCount++; + } + + void incrementCreated() { + createdCount++; + } + + void incrementUpdated() { + updatedCount++; + } + + void incrementDeleted() { + deletedCount++; + } + + void incrementEmailChanged() { + emailChangedCount++; + } + + void incrementUnsubscribed() { + unsubscribedCount++; + } + + void incrementSkipped() { + skippedCount++; + } + + void incrementDatabaseError() { + databaseErrorCount++; + } + + void incrementCommunicationError() { + communicationErrorCount++; + } + + void incrementRateLimitError() { + rateLimitErrorCount++; + } + + void incrementMailjetError() { + mailjetErrorCount++; + } + + void incrementUnexpectedError() { + unexpectedErrorCount++; + } + + int getSuccessCount() { + return successCount; + } + + int getCreatedCount() { + return createdCount; + } + + int getUpdatedCount() { + return updatedCount; + } + + int getDeletedCount() { + return deletedCount; + } + + int getEmailChangedCount() { + return emailChangedCount; + } + + int getUnsubscribedCount() { + return unsubscribedCount; + } + + int getSkippedCount() { + return skippedCount; + } + + int getDatabaseErrorCount() { + return databaseErrorCount; + } + + int getCommunicationErrorCount() { + return communicationErrorCount; + } + + int getRateLimitErrorCount() { + return rateLimitErrorCount; + } + + int getMailjetErrorCount() { + return mailjetErrorCount; + } + + int getUnexpectedErrorCount() { + return unexpectedErrorCount; + } } - void incrementCreated() { createdCount++; } - void incrementUpdated() { updatedCount++; } - void incrementDeleted() { deletedCount++; } - void incrementEmailChanged() { emailChangedCount++; } - void incrementUnsubscribed() { unsubscribedCount++; } - void incrementSkipped() { skippedCount++; } - void incrementDatabaseError() { databaseErrorCount++; } - void incrementCommunicationError() { communicationErrorCount++; } - void incrementRateLimitError() { rateLimitErrorCount++; } - void incrementMailjetError() { mailjetErrorCount++; } - void incrementUnexpectedError() { unexpectedErrorCount++; } - - int getSuccessCount() { return successCount; } - int getCreatedCount() { return createdCount; } - int getUpdatedCount() { return updatedCount; } - int getDeletedCount() { return deletedCount; } - int getEmailChangedCount() { return emailChangedCount; } - int getUnsubscribedCount() { return unsubscribedCount; } - int getSkippedCount() { return skippedCount; } - int getDatabaseErrorCount() { return databaseErrorCount; } - int getCommunicationErrorCount() { return communicationErrorCount; } - int getRateLimitErrorCount() { return rateLimitErrorCount; } - int getMailjetErrorCount() { return mailjetErrorCount; } - int getUnexpectedErrorCount() { return unexpectedErrorCount; } - } } \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java index 5fdc959b6b..ac5cf51c3c 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java @@ -972,7 +972,7 @@ private static StatisticsManager getStatsManager(final UserAccountManager userMa static final String CRON_STRING_0700_DAILY = "0 0 7 * * ?"; static final String CRON_STRING_2000_DAILY = "0 0 20 * * ?"; static final String CRON_STRING_HOURLY = "0 0 * ? * * *"; - static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0/30 * ? * * *"; + static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0 0/4 ? * * *"; static final String CRON_GROUP_NAME_SQL_MAINTENANCE = "SQLMaintenance"; static final String CRON_GROUP_NAME_JAVA_JOB = "JavaJob"; diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index 69d0a30450..66a12a1109 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -37,448 +37,302 @@ public class MailJetApiClientWrapper { - private static final Logger log = LoggerFactory.getLogger(MailJetApiClientWrapper.class); - private static final long DEFAULT_RATE_LIMIT_DELAY_MS = 2000; // 2 seconds between API calls - - private final MailjetClient mailjetClient; - private final String newsListId; - private final String eventsListId; - private final String legalListId; - private final long rateLimitDelayMs; - - // Track last API call time for rate limiting - private long lastApiCallTime = 0; - - /** - * Wrapper for MailjetClient class. - * - * @param mailjetApiKey - MailJet API Key - * @param mailjetApiSecret - MailJet API Client Secret - * @param mailjetNewsListId - MailJet list ID for NEWS_AND_UPDATES - * @param mailjetEventsListId - MailJet list ID for EVENTS - * @param mailjetLegalListId - MailJet list ID for legal notices (all users) - */ - @Inject - public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, - final String mailjetNewsListId, final String mailjetEventsListId, - final String mailjetLegalListId) { - this(mailjetApiKey, mailjetApiSecret, mailjetNewsListId, mailjetEventsListId, - mailjetLegalListId, DEFAULT_RATE_LIMIT_DELAY_MS); - } - - /** - * Wrapper for MailjetClient class with configurable rate limiting. - * - * @param mailjetApiKey - MailJet API Key - * @param mailjetApiSecret - MailJet API Client Secret - * @param mailjetNewsListId - MailJet list ID for NEWS_AND_UPDATES - * @param mailjetEventsListId - MailJet list ID for EVENTS - * @param mailjetLegalListId - MailJet list ID for legal notices (all users) - * @param rateLimitDelayMs - Delay in milliseconds between API calls (default: 2000ms) - */ - public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, - final String mailjetNewsListId, final String mailjetEventsListId, - final String mailjetLegalListId, final long rateLimitDelayMs) { - - if (mailjetApiKey == null || mailjetApiSecret == null) { - throw new IllegalArgumentException("Mailjet API credentials cannot be null"); - } + private static final Logger log = LoggerFactory.getLogger(MailJetApiClientWrapper.class); + + private final MailjetClient mailjetClient; + private final String newsListId; + private final String eventsListId; + private final String legalListId; + + /** + * Wrapper for MailjetClient class. + * + * @param mailjetApiKey - MailJet API Key + * @param mailjetApiSecret - MailJet API Client Secret + * @param mailjetNewsListId - MailJet list ID for NEWS_AND_UPDATES + * @param mailjetEventsListId - MailJet list ID for EVENTS + * @param mailjetLegalListId - MailJet list ID for legal notices (all users) + */ + @Inject + public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, final String mailjetNewsListId, final String mailjetEventsListId, final String mailjetLegalListId) { + + if (mailjetApiKey == null || mailjetApiSecret == null) { + throw new IllegalArgumentException("Mailjet API credentials cannot be null"); + } - ClientOptions options = ClientOptions.builder() - .apiKey(mailjetApiKey) - .apiSecretKey(mailjetApiSecret) - .build(); - - this.mailjetClient = new MailjetClient(options); - this.newsListId = mailjetNewsListId; - this.eventsListId = mailjetEventsListId; - this.legalListId = mailjetLegalListId; - this.rateLimitDelayMs = rateLimitDelayMs; - } - - /** - * Get user details for an existing MailJet account. - * - * @param mailjetIdOrEmail - email address or MailJet user ID - * @return JSONObject of the MailJet user, or null if not found - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { - if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { - log.info("MAILJET - Attempted to get account with null/empty identifier"); - return null; - } + ClientOptions options = ClientOptions.builder().apiKey(mailjetApiKey).apiSecretKey(mailjetApiSecret).build(); - waitForRateLimit(); // Apply rate limiting - - try { - log.info("MAILJET - Fetching Mailjet account: {}", mailjetIdOrEmail); - MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); - MailjetResponse response = mailjetClient.get(request); - - if (response.getStatus() == 404) { - log.info("MAILJET - Mailjet account not found: {}", mailjetIdOrEmail); - return null; - } - - if (response.getStatus() != 200) { - log.warn("MAILJET - Unexpected Mailjet response status {} when fetching account: {}", - response.getStatus(), mailjetIdOrEmail); - throw new MailjetException("Unexpected response status: " + response.getStatus()); - } - - JSONArray responseData = response.getData(); - if (response.getTotal() == 1 && !responseData.isEmpty()) { - log.info("MAILJET - Successfully retrieved Mailjet account: {}", mailjetIdOrEmail); - return responseData.getJSONObject(0); - } - - log.info("MAILJET - Mailjet account not found (total={}): {}", response.getTotal(), mailjetIdOrEmail); - return null; - - } catch (MailjetException e) { - // Check if it's a 404 "Object not found" error - if (e.getMessage() != null && - (e.getMessage().contains("404") || - e.getMessage().toLowerCase().contains("not found") || - e.getMessage().toLowerCase().contains("object not found"))) { - log.info("MAILJET - Mailjet account not found (404): {}. Error: {}", mailjetIdOrEmail, e.getMessage()); - return null; // Treat 404 as "not found", not an error - } - - // Check if it's a timeout/communication issue - if (e.getMessage() != null && - (e.getMessage().toLowerCase().contains("timeout") || - e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJET - Communication error fetching Mailjet account: {}", mailjetIdOrEmail, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - - log.error("MAILJET - Error fetching Mailjet account: {}", mailjetIdOrEmail, e); - throw e; - } - } - - /** - * Perform an asynchronous GDPR-compliant deletion of a MailJet user. - * - * @param mailjetId - MailJet user ID - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetException { - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + this.mailjetClient = new MailjetClient(options); + this.newsListId = mailjetNewsListId; + this.eventsListId = mailjetEventsListId; + this.legalListId = mailjetLegalListId; } - waitForRateLimit(); // Apply rate limiting - - try { - log.info("MAILJET - Deleting Mailjet account: {}", mailjetId); - - MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); - - MailjetResponse response = mailjetClient.delete(request); - - if (response.getStatus() == 204 || response.getStatus() == 200) { - log.info("MAILJET - Successfully deleted Mailjet account: {}", mailjetId); - } else if (response.getStatus() == 404) { - log.warn("MAILJET - Attempted to delete non-existent Mailjet account: {}", mailjetId); - // Don't throw - account is already gone - } else { - log.error("MAILJET - Unexpected response status {} when deleting Mailjet account: {}", - response.getStatus(), mailjetId); - throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); - } - - } catch (MailjetException e) { - // Check if it's a 404 - account already deleted - if (e.getMessage() != null && - (e.getMessage().contains("404") || - e.getMessage().toLowerCase().contains("not found") || - e.getMessage().toLowerCase().contains("object not found"))) { - log.warn("MAILJET - Mailjet account already deleted or not found: {}. Treating as success.", mailjetId); - return; // Already deleted - treat as success - } - - // Check if it's a timeout/communication issue - if (e.getMessage() != null && - (e.getMessage().toLowerCase().contains("timeout") || - e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJET - Communication error deleting Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - log.error("MAILJET - Error deleting Mailjet account: {}", mailjetId, e); - throw e; - } - } - - /** - * Add a new user to MailJet. - *
- * If the user already exists, find by email as a fallback to ensure idempotence and better error recovery. - * - * @param email - email address - * @return the MailJet user ID, or null on failure - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { - if (email == null || email.trim().isEmpty()) { - log.warn("MAILJET - Attempted to create Mailjet account with null/empty email"); - return null; - } + /** + * Get user details for an existing MailJet account. + * + * @param mailjetIdOrEmail - email address or MailJet user ID + * @return JSONObject of the MailJet user, or null if not found + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { + if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { + log.debug("Attempted to get account with null/empty identifier"); + return null; + } - String normalizedEmail = email.trim().toLowerCase(); + try { + MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); + MailjetResponse response = mailjetClient.get(request); - waitForRateLimit(); // Apply rate limiting + if (response.getStatus() == 404) { + return null; + } - try { - log.info("MAILJET - Creating Mailjet account for email: {}", maskEmail(normalizedEmail)); + if (response.getStatus() != 200) { + log.warn("Unexpected Mailjet response status {} when fetching account", response.getStatus()); + throw new MailjetException("Unexpected response status: " + response.getStatus()); + } - MailjetRequest request = new MailjetRequest(Contact.resource) - .property(Contact.EMAIL, normalizedEmail); - MailjetResponse response = mailjetClient.post(request); + JSONArray responseData = response.getData(); + if (response.getTotal() == 1 && !responseData.isEmpty()) { + return responseData.getJSONObject(0); + } - if (response.getStatus() == 201 || response.getStatus() == 200) { - JSONObject responseData = response.getData().getJSONObject(0); - String mailjetId = String.valueOf(responseData.get("ID")); - log.info("MAILJET - Successfully created Mailjet account {} for email: {}", - mailjetId, maskEmail(normalizedEmail)); - return mailjetId; - } + return null; - log.error("MAILJET - Unexpected response status {} when creating Mailjet account for: {}", - response.getStatus(), maskEmail(normalizedEmail)); - throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + } catch (MailjetException e) { + if (isNotFoundException(e)) { + return null; + } - } catch (MailjetClientRequestException e) { - // Check if user already exists - if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { - log.info("MAILJET - User already exists in Mailjet for email: {}. Fetching existing account.", - maskEmail(normalizedEmail)); + if (isCommunicationException(e)) { + log.error("Communication error fetching Mailjet account", e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } - try { - JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); - if (existingAccount != null) { - String mailjetId = String.valueOf(existingAccount.get("ID")); - log.info("MAILJET - Retrieved existing Mailjet account {} for email: {}", - mailjetId, maskEmail(normalizedEmail)); - return mailjetId; - } else { - log.error("MAILJET - User reported as existing but couldn't fetch account for: {}", - maskEmail(normalizedEmail)); - throw new MailjetException("Account exists but couldn't be retrieved"); - } - } catch (JSONException je) { - log.error("MAILJET - JSON parsing error when retrieving existing account for: {}", - maskEmail(normalizedEmail), je); - throw new MailjetException("Failed to parse existing account data", je); + log.error("Error fetching Mailjet account", e); + throw e; } - } else { - log.error("MAILJET - Failed to create Mailjet account for: {}. Error: {}", - maskEmail(normalizedEmail), e.getMessage(), e); - throw new MailjetException("Failed to create account: " + e.getMessage(), e); - } - - } catch (MailjetException e) { - // Check if it's a timeout/communication issue - if (e.getMessage() != null && - (e.getMessage().toLowerCase().contains("timeout") || - e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJET - Communication error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - log.error("MAILJET - Error creating Mailjet account for: {}", maskEmail(normalizedEmail), e); - throw e; - - } catch (JSONException e) { - log.error("MAILJET - JSON parsing error when creating account for: {}", maskEmail(normalizedEmail), e); - throw new MailjetException("Failed to parse Mailjet response", e); - } - } - - /** - * Update user details for an existing MailJet account. - * - * @param mailjetId - MailJet user ID - * @param firstName - first name of user for contact details - * @param role - role of user for contact details - * @param emailVerificationStatus - verification status of user for contact details - * @param stage - stages of GCSE or A Level - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public void updateUserProperties(final String mailjetId, final String firstName, - final String role, final String emailVerificationStatus, - final String stage) throws MailjetException { - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); } - waitForRateLimit(); // Apply rate limiting - - try { - log.info("MAILJET - Updating properties for Mailjet account: {} (role={}, stage={}, status={})", - mailjetId, role, stage, emailVerificationStatus); - - MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId) - .property(Contactdata.DATA, new JSONArray() - .put(new JSONObject().put("Name", "firstname").put("value", firstName != null ? firstName : "")) - .put(new JSONObject().put("Name", "role").put("value", role != null ? role : "")) - .put(new JSONObject().put("Name", "verification_status") - .put("value", emailVerificationStatus != null ? emailVerificationStatus : "")) - .put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown")) - ); - - MailjetResponse response = mailjetClient.put(request); - - if (response.getStatus() == 200 && response.getTotal() == 1) { - log.info("MAILJET - Successfully updated properties for Mailjet account: {}", mailjetId); - } else { - log.error("MAILJET - Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", - mailjetId, response.getStatus(), response.getTotal()); - throw new MailjetException( - String.format("Failed to update user properties. Status: %d, Total: %d", - response.getStatus(), response.getTotal())); - } - - } catch (MailjetException e) { - // Check if it's a 404 - contact not found - if (e.getMessage() != null && - (e.getMessage().contains("404") || - e.getMessage().toLowerCase().contains("not found") || - e.getMessage().toLowerCase().contains("object not found"))) { - log.error("MAILJET - Mailjet contact not found when updating properties: {}. The contact may have been deleted.", mailjetId); - throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); - } - - // Check if it's a timeout/communication issue - if (e.getMessage() != null && - (e.getMessage().toLowerCase().contains("timeout") || - e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJET - Communication error updating properties for Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - log.error("MAILJET - Error updating properties for Mailjet account: {}", mailjetId, e); - throw e; - } - } - - /** - * Update user list subscriptions for an existing MailJet account. - * - * @param mailjetId - MailJet user ID - * @param newsEmails - subscription action to take for news emails - * @param eventsEmails - subscription action to take for events emails - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public void updateUserSubscriptions(final String mailjetId, - final MailJetSubscriptionAction newsEmails, - final MailJetSubscriptionAction eventsEmails) - throws MailjetException { - - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); - } + /** + * Perform an asynchronous GDPR-compliant deletion of a MailJet user. + * + * @param mailjetId - MailJet user ID + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetException { + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } - if (newsEmails == null || eventsEmails == null) { - throw new IllegalArgumentException("Subscription actions cannot be null"); + try { + MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); + MailjetResponse response = mailjetClient.delete(request); + + if (response.getStatus() == 204 || response.getStatus() == 200) { + log.info("Successfully deleted Mailjet account: {}", mailjetId); + } else if (response.getStatus() == 404) { + log.debug("Attempted to delete non-existent Mailjet account: {}", mailjetId); + } else { + log.error("Unexpected response status {} when deleting Mailjet account", response.getStatus()); + throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); + } + + } catch (MailjetException e) { + if (isNotFoundException(e)) { + log.debug("Mailjet account already deleted or not found: {}", mailjetId); + return; + } + + if (isCommunicationException(e)) { + log.error("Communication error deleting Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error deleting Mailjet account: {}", mailjetId, e); + throw e; + } } - waitForRateLimit(); // Apply rate limiting - - try { - log.info("MAILJET - Updating subscriptions for Mailjet account: {} (news={}, events={})", - mailjetId, newsEmails, eventsEmails); - - MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId) - .property(ContactManagecontactslists.CONTACTSLISTS, new JSONArray() - .put(new JSONObject() - .put(ContactslistImportList.LISTID, legalListId) - .put(ContactslistImportList.ACTION, MailJetSubscriptionAction.FORCE_SUBSCRIBE.getValue())) - .put(new JSONObject() - .put(ContactslistImportList.LISTID, newsListId) - .put(ContactslistImportList.ACTION, newsEmails.getValue())) - .put(new JSONObject() - .put(ContactslistImportList.LISTID, eventsListId) - .put(ContactslistImportList.ACTION, eventsEmails.getValue())) - ); - - MailjetResponse response = mailjetClient.post(request); - - if (response.getStatus() == 201 && response.getTotal() == 1) { - log.info("MAILJET - Successfully updated subscriptions for Mailjet account: {}", mailjetId); - } else { - log.error("MAILJET - Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", - mailjetId, response.getStatus(), response.getTotal()); - throw new MailjetException( - String.format("Failed to update user subscriptions. Status: %d, Total: %d", - response.getStatus(), response.getTotal())); - } - - } catch (MailjetException e) { - // Check if it's a 404 - contact not found - if (e.getMessage() != null && - (e.getMessage().contains("404") || - e.getMessage().toLowerCase().contains("not found") || - e.getMessage().toLowerCase().contains("object not found"))) { - log.error("MAILJET - Mailjet contact not found when updating subscriptions: {}. The contact may have been deleted.", mailjetId); - throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); - } - - // Check if it's a timeout/communication issue - if (e.getMessage() != null && - (e.getMessage().toLowerCase().contains("timeout") || - e.getMessage().toLowerCase().contains("connection"))) { - log.error("MAILJET - Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - log.error("MAILJET - Error updating subscriptions for Mailjet account: {}", mailjetId, e); - throw e; + /** + * Add a new user to MailJet. + *
+ * If the user already exists, find by email as a fallback to ensure idempotence. + * + * @param email - email address + * @return the MailJet user ID, or null on failure + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { + if (email == null || email.trim().isEmpty()) { + log.warn("Attempted to create Mailjet account with null/empty email"); + return null; + } + + String normalizedEmail = email.trim().toLowerCase(); + + try { + MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); + MailjetResponse response = mailjetClient.post(request); + + if (response.getStatus() == 201 || response.getStatus() == 200) { + JSONObject responseData = response.getData().getJSONObject(0); + String mailjetId = String.valueOf(responseData.get("ID")); + log.info("Successfully created Mailjet account: {}", mailjetId); + return mailjetId; + } + + log.error("Unexpected response status {} when creating Mailjet account", response.getStatus()); + throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + + } catch (MailjetClientRequestException e) { + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { + log.debug("User already exists in Mailjet, fetching existing account"); + + try { + JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); + if (existingAccount != null) { + String mailjetId = String.valueOf(existingAccount.get("ID")); + log.info("Retrieved existing Mailjet account: {}", mailjetId); + return mailjetId; + } else { + log.error("User reported as existing but couldn't fetch account"); + throw new MailjetException("Account exists but couldn't be retrieved"); + } + } catch (JSONException je) { + log.error("JSON parsing error when retrieving existing account", je); + throw new MailjetException("Failed to parse existing account data", je); + } + } else { + log.error("Failed to create Mailjet account: {}", e.getMessage(), e); + throw new MailjetException("Failed to create account: " + e.getMessage(), e); + } + + } catch (MailjetException e) { + if (isCommunicationException(e)) { + log.error("Communication error creating Mailjet account", e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error creating Mailjet account", e); + throw e; + + } catch (JSONException e) { + log.error("JSON parsing error when creating account", e); + throw new MailjetException("Failed to parse Mailjet response", e); + } } - } - - /** - * Mask email for logging purposes. - */ - private String maskEmail(String email) { - if (email == null || email.isEmpty()) { - return "[empty]"; + + /** + * Update user details for an existing MailJet account. + * + * @param mailjetId - MailJet user ID + * @param firstName - first name of user for contact details + * @param role - role of user for contact details + * @param emailVerificationStatus - verification status of user for contact details + * @param stage - stages of GCSE or A Level + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public void updateUserProperties(final String mailjetId, final String firstName, final String role, final String emailVerificationStatus, final String stage) throws MailjetException { + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + + try { + MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId).property(Contactdata.DATA, new JSONArray().put(new JSONObject().put("Name", "firstname").put("value", firstName != null ? firstName : "")).put(new JSONObject().put("Name", "role").put("value", role != null ? role : "")).put(new JSONObject().put("Name", "verification_status").put("value", emailVerificationStatus != null ? emailVerificationStatus : "")).put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown"))); + + MailjetResponse response = mailjetClient.put(request); + + if (response.getStatus() == 200 && response.getTotal() == 1) { + log.debug("Successfully updated properties for Mailjet account: {}", mailjetId); + } else { + log.error("Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); + throw new MailjetException(String.format("Failed to update user properties. Status: %d, Total: %d", response.getStatus(), response.getTotal())); + } + + } catch (MailjetException e) { + if (isNotFoundException(e)) { + log.error("Mailjet contact not found when updating properties: {}. Contact may have been deleted", mailjetId); + throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); + } + + if (isCommunicationException(e)) { + log.error("Communication error updating properties for Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error updating properties for Mailjet account: {}", mailjetId, e); + throw e; + } } - int atIndex = email.indexOf('@'); - if (atIndex <= 0) { - return email.substring(0, Math.min(3, email.length())) + "***"; + /** + * Update user list subscriptions for an existing MailJet account. + * + * @param mailjetId - MailJet user ID + * @param newsEmails - subscription action to take for news emails + * @param eventsEmails - subscription action to take for events emails + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public void updateUserSubscriptions(final String mailjetId, final MailJetSubscriptionAction newsEmails, final MailJetSubscriptionAction eventsEmails) throws MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + + if (newsEmails == null || eventsEmails == null) { + throw new IllegalArgumentException("Subscription actions cannot be null"); + } + + try { + MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId).property(ContactManagecontactslists.CONTACTSLISTS, new JSONArray().put(new JSONObject().put(ContactslistImportList.LISTID, legalListId).put(ContactslistImportList.ACTION, MailJetSubscriptionAction.FORCE_SUBSCRIBE.getValue())).put(new JSONObject().put(ContactslistImportList.LISTID, newsListId).put(ContactslistImportList.ACTION, newsEmails.getValue())).put(new JSONObject().put(ContactslistImportList.LISTID, eventsListId).put(ContactslistImportList.ACTION, eventsEmails.getValue()))); + + MailjetResponse response = mailjetClient.post(request); + + if (response.getStatus() == 201 && response.getTotal() == 1) { + log.debug("Successfully updated subscriptions for Mailjet account: {}", mailjetId); + } else { + log.error("Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); + throw new MailjetException(String.format("Failed to update user subscriptions. Status: %d, Total: %d", response.getStatus(), response.getTotal())); + } + + } catch (MailjetException e) { + if (isNotFoundException(e)) { + log.error("Mailjet contact not found when updating subscriptions: {}. Contact may have been deleted", mailjetId); + throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); + } + + if (isCommunicationException(e)) { + log.error("Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error updating subscriptions for Mailjet account: {}", mailjetId, e); + throw e; + } } - String localPart = email.substring(0, atIndex); - String domain = email.substring(atIndex); - String masked = localPart.substring(0, Math.min(3, localPart.length())) + "***"; - - return masked + domain; - } - - /** - * Wait for rate limiting before making an API call. - * Ensures minimum delay between consecutive API calls to avoid rate limits. - * - * This method is synchronized to ensure thread-safety when multiple threads - * might be using the same MailJetApiClientWrapper instance. - */ - private synchronized void waitForRateLimit() { - long currentTime = System.currentTimeMillis(); - long timeSinceLastCall = currentTime - lastApiCallTime; - - if (timeSinceLastCall < rateLimitDelayMs && lastApiCallTime > 0) { - long waitTime = rateLimitDelayMs - timeSinceLastCall; - log.info("MAILJET - Rate limiting: waiting {}ms before next API call", waitTime); - - try { - Thread.sleep(waitTime); - } catch (InterruptedException e) { - log.warn("MAILJET - Rate limit wait interrupted", e); - Thread.currentThread().interrupt(); - } + /** + * Check if exception is a 404 not found error. + */ + private boolean isNotFoundException(MailjetException e) { + if (e.getMessage() == null) { + return false; + } + String msg = e.getMessage().toLowerCase(); + return msg.contains("404") || msg.contains("not found") || msg.contains("object not found"); } - lastApiCallTime = System.currentTimeMillis(); - } -} \ No newline at end of file + /** + * Check if exception is a communication/timeout error. + */ + private boolean isCommunicationException(MailjetException e) { + if (e.getMessage() == null) { + return false; + } + String msg = e.getMessage().toLowerCase(); + return msg.contains("timeout") || msg.contains("connection"); + } +} From c7332f582b41ed262d76dbe1f576b3e33f344b00 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 11:38:41 +0200 Subject: [PATCH 12/22] PATCH 19 --- .../api/managers/ExternalAccountManager.java | 2 + .../SegueGuiceConfigurationModule.java | 2106 +++++++++-------- 2 files changed, 1056 insertions(+), 1052 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index af48b5f1fb..85d56a7802 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -19,7 +19,9 @@ import com.mailjet.client.errors.MailjetClientCommunicationException; import com.mailjet.client.errors.MailjetException; import com.mailjet.client.errors.MailjetRateLimitException; + import java.util.List; + import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java index ac5cf51c3c..2a63d1a481 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java @@ -51,6 +51,7 @@ import com.google.inject.name.Names; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; + import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -64,6 +65,7 @@ import java.util.Map; import java.util.Properties; import java.util.Set; + import org.apache.commons.lang3.SystemUtils; import org.elasticsearch.client.RestHighLevelClient; import org.slf4j.Logger; @@ -192,1123 +194,1123 @@ * This class is responsible for injecting configuration values for persistence related classes. */ public class SegueGuiceConfigurationModule extends AbstractModule implements ServletContextListener { - private static final Logger log = LoggerFactory.getLogger(SegueGuiceConfigurationModule.class); - - private static String version = null; - - private static Injector injector = null; - - private static PropertiesLoader globalProperties = null; - - // Singletons - we only ever want there to be one instance of each of these. - private static PostgresSqlDb postgresDB; - private static ContentMapperUtils mapperUtils = null; - private static GitContentManager contentManager = null; - private static RestHighLevelClient elasticSearchClient = null; - private static UserAccountManager userManager = null; - private static UserAuthenticationManager userAuthenticationManager = null; - private static IQuestionAttemptManager questionPersistenceManager = null; - private static SegueJobService segueJobService = null; - - private static LogManagerEventPublisher logManager; - private static EmailManager emailCommunicationQueue = null; - private static IMisuseMonitor misuseMonitor = null; - private static IMetricsExporter metricsExporter = null; - private static StatisticsManager statsManager = null; - private static GroupManager groupManager = null; - private static IExternalAccountManager externalAccountManager = null; - private static GameboardPersistenceManager gameboardPersistenceManager = null; - private static SchoolListReader schoolListReader = null; - private static AssignmentManager assignmentManager = null; - private static IGroupObserver groupObserver = null; - - private static Collection> contextListeners; - private static final Map>> classesByPackage = new HashMap<>(); - - /** - * A setter method that is mostly useful for testing. It populates the global properties static value if it has not - * previously been set. - * - * @param globalProperties PropertiesLoader object to be used for loading properties - * (if it has not previously been set). - */ - public static void setGlobalPropertiesIfNotSet(final PropertiesLoader globalProperties) { - if (SegueGuiceConfigurationModule.globalProperties == null) { - SegueGuiceConfigurationModule.globalProperties = globalProperties; + private static final Logger log = LoggerFactory.getLogger(SegueGuiceConfigurationModule.class); + + private static String version = null; + + private static Injector injector = null; + + private static PropertiesLoader globalProperties = null; + + // Singletons - we only ever want there to be one instance of each of these. + private static PostgresSqlDb postgresDB; + private static ContentMapperUtils mapperUtils = null; + private static GitContentManager contentManager = null; + private static RestHighLevelClient elasticSearchClient = null; + private static UserAccountManager userManager = null; + private static UserAuthenticationManager userAuthenticationManager = null; + private static IQuestionAttemptManager questionPersistenceManager = null; + private static SegueJobService segueJobService = null; + + private static LogManagerEventPublisher logManager; + private static EmailManager emailCommunicationQueue = null; + private static IMisuseMonitor misuseMonitor = null; + private static IMetricsExporter metricsExporter = null; + private static StatisticsManager statsManager = null; + private static GroupManager groupManager = null; + private static IExternalAccountManager externalAccountManager = null; + private static GameboardPersistenceManager gameboardPersistenceManager = null; + private static SchoolListReader schoolListReader = null; + private static AssignmentManager assignmentManager = null; + private static IGroupObserver groupObserver = null; + + private static Collection> contextListeners; + private static final Map>> classesByPackage = new HashMap<>(); + + /** + * A setter method that is mostly useful for testing. It populates the global properties static value if it has not + * previously been set. + * + * @param globalProperties PropertiesLoader object to be used for loading properties + * (if it has not previously been set). + */ + public static void setGlobalPropertiesIfNotSet(final PropertiesLoader globalProperties) { + if (SegueGuiceConfigurationModule.globalProperties == null) { + SegueGuiceConfigurationModule.globalProperties = globalProperties; + } + } + + /** + * Create a SegueGuiceConfigurationModule. + */ + public SegueGuiceConfigurationModule() { + if (globalProperties == null) { + // check the following places to determine where config file location may be. + // 1) system env variable, 2) java param (system property), 3) use a default from the constant file. + String configLocation = SystemUtils.IS_OS_LINUX ? DEFAULT_LINUX_CONFIG_LOCATION : null; + if (System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY) != null) { + configLocation = System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY); + } + if (System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY) != null) { + configLocation = System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY); + } + + try { + if (null == configLocation) { + throw new FileNotFoundException(SEGUE_CONFIG_LOCATION_NOT_SPECIFIED_MESSAGE); + } + + globalProperties = new PropertiesLoader(configLocation); + + log.info("Segue using configuration file: {}", configLocation); + + } catch (IOException e) { + log.error("Error loading properties file.", e); + } + } } - } - - /** - * Create a SegueGuiceConfigurationModule. - */ - public SegueGuiceConfigurationModule() { - if (globalProperties == null) { - // check the following places to determine where config file location may be. - // 1) system env variable, 2) java param (system property), 3) use a default from the constant file. - String configLocation = SystemUtils.IS_OS_LINUX ? DEFAULT_LINUX_CONFIG_LOCATION : null; - if (System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY) != null) { - configLocation = System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY); - } - if (System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY) != null) { - configLocation = System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY); - } - - try { - if (null == configLocation) { - throw new FileNotFoundException(SEGUE_CONFIG_LOCATION_NOT_SPECIFIED_MESSAGE); + + @Override + protected void configure() { + try { + this.configureProperties(); + this.configureDataPersistence(); + this.configureSegueSearch(); + this.configureAuthenticationProviders(); + this.configureApplicationManagers(); + + } catch (IOException e) { + log.error("IOException during setup process.", e); } + } + + /** + * Extract properties and bind them to constants. + */ + private void configureProperties() { + // Properties loader + bind(PropertiesLoader.class).toInstance(globalProperties); + + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_NAME, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_ADDRESS, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_INFO_PORT, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_USERNAME, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_PASSWORD, globalProperties); + + this.bindConstantToProperty(Constants.HOST_NAME, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_SERVER, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_USERNAME, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_PASSWORD, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_PORT, globalProperties); + this.bindConstantToProperty(Constants.MAIL_FROM_ADDRESS, globalProperties); + this.bindConstantToProperty(Constants.MAIL_NAME, globalProperties); + + this.bindConstantToProperty(Constants.LOGGING_ENABLED, globalProperties); + + this.bindConstantToProperty(Constants.SCHOOL_CSV_LIST_PATH, globalProperties); + + this.bindConstantToProperty(CONTENT_INDEX, globalProperties); + + this.bindConstantToProperty(Constants.API_METRICS_EXPORT_PORT, globalProperties); + + this.bind(String.class).toProvider(() -> { + // Any binding to String without a matching @Named annotation will always get the empty string + // which seems incredibly likely to cause errors and rarely to be intended behaviour, + // so throw an error early in DEV and log an error in PROD. + try { + throw new IllegalArgumentException("Binding a String without a matching @Named annotation"); + } catch (IllegalArgumentException e) { + if (globalProperties.getProperty(SEGUE_APP_ENVIRONMENT).equals(DEV.name())) { + throw e; + } + log.error("Binding a String without a matching @Named annotation", e); + } + return ""; + }); + } - globalProperties = new PropertiesLoader(configLocation); + /** + * Configure all things persistence-related. + * + * @throws IOException when we cannot load the database. + */ + private void configureDataPersistence() throws IOException { + this.bindConstantToProperty(Constants.SEGUE_DB_NAME, globalProperties); + + // postgres + this.bindConstantToProperty(Constants.POSTGRES_DB_URL, globalProperties); + this.bindConstantToProperty(Constants.POSTGRES_DB_USER, globalProperties); + this.bindConstantToProperty(Constants.POSTGRES_DB_PASSWORD, globalProperties); + + // GitDb + bind(GitDb.class).toInstance( + new GitDb(globalProperties.getProperty(Constants.LOCAL_GIT_DB), globalProperties + .getProperty(Constants.REMOTE_GIT_SSH_URL), globalProperties + .getProperty(Constants.REMOTE_GIT_SSH_KEY_PATH))); + + bind(IUserGroupPersistenceManager.class).to(PgUserGroupPersistenceManager.class); + bind(IAssociationDataManager.class).to(PgAssociationDataManager.class); + bind(IAssignmentPersistenceManager.class).to(PgAssignmentPersistenceManager.class); + bind(IQuizAssignmentPersistenceManager.class).to(PgQuizAssignmentPersistenceManager.class); + bind(IQuizAttemptPersistenceManager.class).to(PgQuizAttemptPersistenceManager.class); + bind(IQuizQuestionAttemptPersistenceManager.class).to(PgQuizQuestionAttemptPersistenceManager.class); + bind(IUserBadgePersistenceManager.class).to(PgUserBadgePersistenceManager.class); + } - log.info("Segue using configuration file: {}", configLocation); + /** + * Configure segue search classes. + */ + private void configureSegueSearch() { + bind(ISearchProvider.class).to(ElasticSearchProvider.class); + } - } catch (IOException e) { - log.error("Error loading properties file.", e); - } + /** + * Configure user security related classes. + */ + private void configureAuthenticationProviders() { + MapBinder mapBinder = MapBinder.newMapBinder(binder(), + AuthenticationProvider.class, IAuthenticator.class); + + this.bindConstantToProperty(Constants.HMAC_SALT, globalProperties); + //Google reCAPTCHA + this.bindConstantToProperty(Constants.GOOGLE_RECAPTCHA_SECRET, globalProperties); + + // Configure security providers + // Google + this.bindConstantToProperty(Constants.GOOGLE_CLIENT_SECRET_LOCATION, globalProperties); + this.bindConstantToProperty(Constants.GOOGLE_CALLBACK_URI, globalProperties); + this.bindConstantToProperty(Constants.GOOGLE_OAUTH_SCOPES, globalProperties); + mapBinder.addBinding(AuthenticationProvider.GOOGLE).to(GoogleAuthenticator.class); + + // Facebook + this.bindConstantToProperty(Constants.FACEBOOK_SECRET, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_CLIENT_ID, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_CALLBACK_URI, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_OAUTH_SCOPES, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_USER_FIELDS, globalProperties); + mapBinder.addBinding(AuthenticationProvider.FACEBOOK).to(FacebookAuthenticator.class); + + // Twitter + this.bindConstantToProperty(Constants.TWITTER_SECRET, globalProperties); + this.bindConstantToProperty(Constants.TWITTER_CLIENT_ID, globalProperties); + this.bindConstantToProperty(Constants.TWITTER_CALLBACK_URI, globalProperties); + mapBinder.addBinding(AuthenticationProvider.TWITTER).to(TwitterAuthenticator.class); + + // Segue local + mapBinder.addBinding(AuthenticationProvider.SEGUE).to(SegueLocalAuthenticator.class); } - } - - @Override - protected void configure() { - try { - this.configureProperties(); - this.configureDataPersistence(); - this.configureSegueSearch(); - this.configureAuthenticationProviders(); - this.configureApplicationManagers(); - - } catch (IOException e) { - log.error("IOException during setup process.", e); + + /** + * Deals with application data managers. + */ + private void configureApplicationManagers() { + bind(IUserDataManager.class).to(PgUsers.class); + + bind(IAnonymousUserDataManager.class).to(PgAnonymousUsers.class); + + bind(IPasswordDataManager.class).to(PgPasswordDataManager.class); + + bind(ICommunicator.class).to(EmailCommunicator.class); + + bind(AbstractUserPreferenceManager.class).to(PgUserPreferenceManager.class); + + bind(IUserAlerts.class).to(PgUserAlerts.class); + + bind(IUserStreaksManager.class).to(PgUserStreakManager.class); + + bind(IStatisticsManager.class).to(StatisticsManager.class); + + bind(ITransactionManager.class).to(PgTransactionManager.class); + + bind(ITOTPDataManager.class).to(PgTOTPDataManager.class); + + bind(ISecondFactorAuthenticator.class).to(SegueTOTPAuthenticator.class); } - } - - /** - * Extract properties and bind them to constants. - */ - private void configureProperties() { - // Properties loader - bind(PropertiesLoader.class).toInstance(globalProperties); - - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_NAME, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_ADDRESS, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_INFO_PORT, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_USERNAME, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_PASSWORD, globalProperties); - - this.bindConstantToProperty(Constants.HOST_NAME, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_SERVER, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_USERNAME, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_PASSWORD, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_PORT, globalProperties); - this.bindConstantToProperty(Constants.MAIL_FROM_ADDRESS, globalProperties); - this.bindConstantToProperty(Constants.MAIL_NAME, globalProperties); - - this.bindConstantToProperty(Constants.LOGGING_ENABLED, globalProperties); - - this.bindConstantToProperty(Constants.SCHOOL_CSV_LIST_PATH, globalProperties); - - this.bindConstantToProperty(CONTENT_INDEX, globalProperties); - - this.bindConstantToProperty(Constants.API_METRICS_EXPORT_PORT, globalProperties); - - this.bind(String.class).toProvider(() -> { - // Any binding to String without a matching @Named annotation will always get the empty string - // which seems incredibly likely to cause errors and rarely to be intended behaviour, - // so throw an error early in DEV and log an error in PROD. - try { - throw new IllegalArgumentException("Binding a String without a matching @Named annotation"); - } catch (IllegalArgumentException e) { - if (globalProperties.getProperty(SEGUE_APP_ENVIRONMENT).equals(DEV.name())) { - throw e; + + + @Inject + @Provides + @Singleton + private static IMetricsExporter getMetricsExporter( + @Named(Constants.API_METRICS_EXPORT_PORT) final int port) { + if (null == metricsExporter) { + try { + log.info("Creating MetricsExporter on port ({})", port); + metricsExporter = new PrometheusMetricsExporter(port); + log.info("Exporting default JVM metrics."); + metricsExporter.exposeJvmMetrics(); + } catch (IOException e) { + log.error("Could not create MetricsExporter on port ({})", port); + return null; + } } - log.error("Binding a String without a matching @Named annotation", e); - } - return ""; - }); - } - - /** - * Configure all things persistence-related. - * - * @throws IOException when we cannot load the database. - */ - private void configureDataPersistence() throws IOException { - this.bindConstantToProperty(Constants.SEGUE_DB_NAME, globalProperties); - - // postgres - this.bindConstantToProperty(Constants.POSTGRES_DB_URL, globalProperties); - this.bindConstantToProperty(Constants.POSTGRES_DB_USER, globalProperties); - this.bindConstantToProperty(Constants.POSTGRES_DB_PASSWORD, globalProperties); - - // GitDb - bind(GitDb.class).toInstance( - new GitDb(globalProperties.getProperty(Constants.LOCAL_GIT_DB), globalProperties - .getProperty(Constants.REMOTE_GIT_SSH_URL), globalProperties - .getProperty(Constants.REMOTE_GIT_SSH_KEY_PATH))); - - bind(IUserGroupPersistenceManager.class).to(PgUserGroupPersistenceManager.class); - bind(IAssociationDataManager.class).to(PgAssociationDataManager.class); - bind(IAssignmentPersistenceManager.class).to(PgAssignmentPersistenceManager.class); - bind(IQuizAssignmentPersistenceManager.class).to(PgQuizAssignmentPersistenceManager.class); - bind(IQuizAttemptPersistenceManager.class).to(PgQuizAttemptPersistenceManager.class); - bind(IQuizQuestionAttemptPersistenceManager.class).to(PgQuizQuestionAttemptPersistenceManager.class); - bind(IUserBadgePersistenceManager.class).to(PgUserBadgePersistenceManager.class); - } - - /** - * Configure segue search classes. - */ - private void configureSegueSearch() { - bind(ISearchProvider.class).to(ElasticSearchProvider.class); - } - - /** - * Configure user security related classes. - */ - private void configureAuthenticationProviders() { - MapBinder mapBinder = MapBinder.newMapBinder(binder(), - AuthenticationProvider.class, IAuthenticator.class); - - this.bindConstantToProperty(Constants.HMAC_SALT, globalProperties); - //Google reCAPTCHA - this.bindConstantToProperty(Constants.GOOGLE_RECAPTCHA_SECRET, globalProperties); - - // Configure security providers - // Google - this.bindConstantToProperty(Constants.GOOGLE_CLIENT_SECRET_LOCATION, globalProperties); - this.bindConstantToProperty(Constants.GOOGLE_CALLBACK_URI, globalProperties); - this.bindConstantToProperty(Constants.GOOGLE_OAUTH_SCOPES, globalProperties); - mapBinder.addBinding(AuthenticationProvider.GOOGLE).to(GoogleAuthenticator.class); - - // Facebook - this.bindConstantToProperty(Constants.FACEBOOK_SECRET, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_CLIENT_ID, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_CALLBACK_URI, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_OAUTH_SCOPES, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_USER_FIELDS, globalProperties); - mapBinder.addBinding(AuthenticationProvider.FACEBOOK).to(FacebookAuthenticator.class); - - // Twitter - this.bindConstantToProperty(Constants.TWITTER_SECRET, globalProperties); - this.bindConstantToProperty(Constants.TWITTER_CLIENT_ID, globalProperties); - this.bindConstantToProperty(Constants.TWITTER_CALLBACK_URI, globalProperties); - mapBinder.addBinding(AuthenticationProvider.TWITTER).to(TwitterAuthenticator.class); - - // Segue local - mapBinder.addBinding(AuthenticationProvider.SEGUE).to(SegueLocalAuthenticator.class); - } - - /** - * Deals with application data managers. - */ - private void configureApplicationManagers() { - bind(IUserDataManager.class).to(PgUsers.class); - - bind(IAnonymousUserDataManager.class).to(PgAnonymousUsers.class); - - bind(IPasswordDataManager.class).to(PgPasswordDataManager.class); - - bind(ICommunicator.class).to(EmailCommunicator.class); - - bind(AbstractUserPreferenceManager.class).to(PgUserPreferenceManager.class); - - bind(IUserAlerts.class).to(PgUserAlerts.class); - - bind(IUserStreaksManager.class).to(PgUserStreakManager.class); - - bind(IStatisticsManager.class).to(StatisticsManager.class); - - bind(ITransactionManager.class).to(PgTransactionManager.class); - - bind(ITOTPDataManager.class).to(PgTOTPDataManager.class); - - bind(ISecondFactorAuthenticator.class).to(SegueTOTPAuthenticator.class); - } - - - @Inject - @Provides - @Singleton - private static IMetricsExporter getMetricsExporter( - @Named(Constants.API_METRICS_EXPORT_PORT) final int port) { - if (null == metricsExporter) { - try { - log.info("Creating MetricsExporter on port ({})", port); - metricsExporter = new PrometheusMetricsExporter(port); - log.info("Exporting default JVM metrics."); - metricsExporter.exposeJvmMetrics(); - } catch (IOException e) { - log.error("Could not create MetricsExporter on port ({})", port); - return null; - } + return metricsExporter; } - return metricsExporter; - } - - /** - * This provides a singleton of the elasticSearch client that can be used by Guice. - *
- * The client is threadsafe, so we don't need to keep creating new ones. - * - * @param address address of the cluster to create. - * @param port port of the cluster to create. - * @param username username for cluster user. - * @param password password for cluster user. - * @return Client to be injected into ElasticSearch Provider. - */ - @Inject - @Provides - @Singleton - private static RestHighLevelClient getSearchConnectionInformation( - @Named(Constants.SEARCH_CLUSTER_ADDRESS) final String address, - @Named(Constants.SEARCH_CLUSTER_INFO_PORT) final int port, - @Named(Constants.SEARCH_CLUSTER_USERNAME) final String username, - @Named(Constants.SEARCH_CLUSTER_PASSWORD) final String password) { - if (null == elasticSearchClient) { - try { - elasticSearchClient = ElasticSearchProvider.getClient(address, port, username, password); - log.info("Creating singleton of ElasticSearchProvider"); - } catch (UnknownHostException e) { - log.error("Could not create ElasticSearchProvider"); - return null; - } + + /** + * This provides a singleton of the elasticSearch client that can be used by Guice. + *
+ * The client is threadsafe, so we don't need to keep creating new ones. + * + * @param address address of the cluster to create. + * @param port port of the cluster to create. + * @param username username for cluster user. + * @param password password for cluster user. + * @return Client to be injected into ElasticSearch Provider. + */ + @Inject + @Provides + @Singleton + private static RestHighLevelClient getSearchConnectionInformation( + @Named(Constants.SEARCH_CLUSTER_ADDRESS) final String address, + @Named(Constants.SEARCH_CLUSTER_INFO_PORT) final int port, + @Named(Constants.SEARCH_CLUSTER_USERNAME) final String username, + @Named(Constants.SEARCH_CLUSTER_PASSWORD) final String password) { + if (null == elasticSearchClient) { + try { + elasticSearchClient = ElasticSearchProvider.getClient(address, port, username, password); + log.info("Creating singleton of ElasticSearchProvider"); + } catch (UnknownHostException e) { + log.error("Could not create ElasticSearchProvider"); + return null; + } + } + // eventually we want to do something like the below to make sure we get updated clients + // if (elasticSearchClient instanceof TransportClient) { + // TransportClient tc = (TransportClient) elasticSearchClient; + // if (tc.connectedNodes().isEmpty()) { + // tc.close(); + // log.error("The elasticsearch client is not connected to any nodes. Trying to reconnect..."); + // elasticSearchClient = null; + // return getSearchConnectionInformation(clusterName, address, port); + // } + // } + + return elasticSearchClient; } - // eventually we want to do something like the below to make sure we get updated clients - // if (elasticSearchClient instanceof TransportClient) { - // TransportClient tc = (TransportClient) elasticSearchClient; - // if (tc.connectedNodes().isEmpty()) { - // tc.close(); - // log.error("The elasticsearch client is not connected to any nodes. Trying to reconnect..."); - // elasticSearchClient = null; - // return getSearchConnectionInformation(clusterName, address, port); - // } - // } - - return elasticSearchClient; - } - - /** - * This provides a singleton of the git content manager for the segue facade. - *
- * TODO: This is a singleton as the units and tags are stored in memory. If we move these out it can be an instance. - * This would be better as then we can give it a new search provider if the client has closed. - * - * @param database database reference - * @param searchProvider search provider to use - * @param contentMapperUtils content mapper to use. - * @param globalProperties properties loader to use - * @return a fully configured content Manager. - */ - @Inject - @Provides - @Singleton - private static GitContentManager getContentManager(final GitDb database, final ISearchProvider searchProvider, - final ContentMapperUtils contentMapperUtils, - final ContentMapper objectMapper, - final PropertiesLoader globalProperties) { - if (null == contentManager) { - contentManager = - new GitContentManager(database, searchProvider, contentMapperUtils, objectMapper, globalProperties); - log.info("Creating singleton of ContentManager"); + + /** + * This provides a singleton of the git content manager for the segue facade. + *
+ * TODO: This is a singleton as the units and tags are stored in memory. If we move these out it can be an instance. + * This would be better as then we can give it a new search provider if the client has closed. + * + * @param database database reference + * @param searchProvider search provider to use + * @param contentMapperUtils content mapper to use. + * @param globalProperties properties loader to use + * @return a fully configured content Manager. + */ + @Inject + @Provides + @Singleton + private static GitContentManager getContentManager(final GitDb database, final ISearchProvider searchProvider, + final ContentMapperUtils contentMapperUtils, + final ContentMapper objectMapper, + final PropertiesLoader globalProperties) { + if (null == contentManager) { + contentManager = + new GitContentManager(database, searchProvider, contentMapperUtils, objectMapper, globalProperties); + log.info("Creating singleton of ContentManager"); + } + + return contentManager; } - return contentManager; - } - - /** - * This provides a singleton of the LogManager for the Segue facade. - *
- * Note: This is a singleton as logs are created very often and we wanted to minimise the overhead in class - * creation. Although we can convert this to instances if we want to tidy this up. - * - * @param database database reference - * @param loggingEnabled boolean to determine if we should persist log messages. - * @return A fully configured LogManager - */ - @Inject - @Provides - @Singleton - private static ILogManager getLogManager(final PostgresSqlDb database, - @Named(Constants.LOGGING_ENABLED) final boolean loggingEnabled) { - - if (null == logManager) { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - objectMapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); - logManager = new PgLogManagerEventListener(new PgLogManager(database, objectMapper, loggingEnabled)); - - log.info("Creating singleton of LogManager"); - if (loggingEnabled) { - log.info("Log manager configured to record logging."); - } else { - log.info("Log manager configured NOT to record logging."); - } + /** + * This provides a singleton of the LogManager for the Segue facade. + *
+ * Note: This is a singleton as logs are created very often and we wanted to minimise the overhead in class + * creation. Although we can convert this to instances if we want to tidy this up. + * + * @param database database reference + * @param loggingEnabled boolean to determine if we should persist log messages. + * @return A fully configured LogManager + */ + @Inject + @Provides + @Singleton + private static ILogManager getLogManager(final PostgresSqlDb database, + @Named(Constants.LOGGING_ENABLED) final boolean loggingEnabled) { + + if (null == logManager) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + objectMapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); + logManager = new PgLogManagerEventListener(new PgLogManager(database, objectMapper, loggingEnabled)); + + log.info("Creating singleton of LogManager"); + if (loggingEnabled) { + log.info("Log manager configured to record logging."); + } else { + log.info("Log manager configured NOT to record logging."); + } + } + + return logManager; } - return logManager; - } - - /** - * This provides a singleton of the contentVersionController for the segue facade. - * Note: This is a singleton because this content mapper has to use reflection to register all content classes. - * - * @return Content version controller with associated dependencies. - */ - @Inject - @Provides - @Singleton - private static ContentMapperUtils getContentMapper() { - if (null == mapperUtils) { - Set> c = getClasses("uk.ac.cam.cl.dtg"); - mapperUtils = new ContentMapperUtils(c); - log.info("Creating Singleton of the Content Mapper"); + /** + * This provides a singleton of the contentVersionController for the segue facade. + * Note: This is a singleton because this content mapper has to use reflection to register all content classes. + * + * @return Content version controller with associated dependencies. + */ + @Inject + @Provides + @Singleton + private static ContentMapperUtils getContentMapper() { + if (null == mapperUtils) { + Set> c = getClasses("uk.ac.cam.cl.dtg"); + mapperUtils = new ContentMapperUtils(c); + log.info("Creating Singleton of the Content Mapper"); + } + + return mapperUtils; } - return mapperUtils; - } - - /** - * This provides an instance of the SegueLocalAuthenticator. - *
- * - * @param database the database to access userInformation - * @param passwordDataManager the database to access passwords - * @param properties the global system properties - * @return an instance of the queue - */ - @Inject - @Provides - private static SegueLocalAuthenticator getSegueLocalAuthenticator(final IUserDataManager database, - final IPasswordDataManager passwordDataManager, - final PropertiesLoader properties) { - ISegueHashingAlgorithm preferredAlgorithm = new SegueSCryptv1(); - ISegueHashingAlgorithm oldAlgorithm1 = new SeguePBKDF2v1(); - ISegueHashingAlgorithm oldAlgorithm2 = new SeguePBKDF2v2(); - ISegueHashingAlgorithm oldAlgorithm3 = new SeguePBKDF2v3(); - - Map possibleAlgorithms = Map.of( - preferredAlgorithm.hashingAlgorithmName(), preferredAlgorithm, - oldAlgorithm1.hashingAlgorithmName(), oldAlgorithm1, - oldAlgorithm2.hashingAlgorithmName(), oldAlgorithm2, - oldAlgorithm3.hashingAlgorithmName(), oldAlgorithm3 - ); - - return new SegueLocalAuthenticator(database, passwordDataManager, properties, possibleAlgorithms, - preferredAlgorithm); - } - - /** - * This provides a singleton of the e-mail manager class. - *
- * Note: This has to be a singleton because it manages all emails sent using this JVM. - * - * @param properties the properties so we can generate email - * @param emailCommunicator the class the queue will send messages with - * @param userPreferenceManager the class providing email preferences - * @param contentManager the content so we can access email templates - * @param logManager the logManager to log email sent - * @return an instance of the queue - */ - @Inject - @Provides - @Singleton - private static EmailManager getMessageCommunicationQueue(final PropertiesLoader properties, - final EmailCommunicator emailCommunicator, - final AbstractUserPreferenceManager userPreferenceManager, - final GitContentManager contentManager, - final ILogManager logManager) { - - Map globalTokens = Maps.newHashMap(); - globalTokens.put("sig", properties.getProperty(EMAIL_SIGNATURE)); - globalTokens.put("emailPreferencesURL", String.format("https://%s/account#emailpreferences", - properties.getProperty(HOST_NAME))); - globalTokens.put("myAssignmentsURL", String.format("https://%s/assignments", - properties.getProperty(HOST_NAME))); - String myQuizzesURL = String.format("https://%s/tests", properties.getProperty(HOST_NAME)); - globalTokens.put("myQuizzesURL", myQuizzesURL); - globalTokens.put("myTestsURL", myQuizzesURL); - globalTokens.put("myBookedEventsURL", String.format("https://%s/events?show_booked_only=true", - properties.getProperty(HOST_NAME))); - globalTokens.put("contactUsURL", String.format("https://%s/contact", - properties.getProperty(HOST_NAME))); - globalTokens.put("accountURL", String.format("https://%s/account", - properties.getProperty(HOST_NAME))); - globalTokens.put("siteBaseURL", String.format("https://%s", properties.getProperty(HOST_NAME))); - - if (null == emailCommunicationQueue) { - emailCommunicationQueue = new EmailManager(emailCommunicator, userPreferenceManager, properties, - contentManager, logManager, globalTokens); - log.info("Creating singleton of EmailCommunicationQueue"); + /** + * This provides an instance of the SegueLocalAuthenticator. + *
+ * + * @param database the database to access userInformation + * @param passwordDataManager the database to access passwords + * @param properties the global system properties + * @return an instance of the queue + */ + @Inject + @Provides + private static SegueLocalAuthenticator getSegueLocalAuthenticator(final IUserDataManager database, + final IPasswordDataManager passwordDataManager, + final PropertiesLoader properties) { + ISegueHashingAlgorithm preferredAlgorithm = new SegueSCryptv1(); + ISegueHashingAlgorithm oldAlgorithm1 = new SeguePBKDF2v1(); + ISegueHashingAlgorithm oldAlgorithm2 = new SeguePBKDF2v2(); + ISegueHashingAlgorithm oldAlgorithm3 = new SeguePBKDF2v3(); + + Map possibleAlgorithms = Map.of( + preferredAlgorithm.hashingAlgorithmName(), preferredAlgorithm, + oldAlgorithm1.hashingAlgorithmName(), oldAlgorithm1, + oldAlgorithm2.hashingAlgorithmName(), oldAlgorithm2, + oldAlgorithm3.hashingAlgorithmName(), oldAlgorithm3 + ); + + return new SegueLocalAuthenticator(database, passwordDataManager, properties, possibleAlgorithms, + preferredAlgorithm); } - return emailCommunicationQueue; - } - - /** - * This provides a singleton of the UserManager for various facades. - *
- * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. - * - * @param database the user persistence manager. - * @param properties properties loader - * @param providersToRegister list of known providers. - * @param emailQueue so that we can send e-mails. - * @return Content version controller with associated dependencies. - */ - @Inject - @Provides - @Singleton - private UserAuthenticationManager getUserAuthenticationManager( - final IUserDataManager database, final PropertiesLoader properties, - final Map providersToRegister, final EmailManager emailQueue) { - if (null == userAuthenticationManager) { - userAuthenticationManager = new UserAuthenticationManager(database, properties, providersToRegister, emailQueue); - log.info("Creating singleton of UserAuthenticationManager"); + + /** + * This provides a singleton of the e-mail manager class. + *
+ * Note: This has to be a singleton because it manages all emails sent using this JVM. + * + * @param properties the properties so we can generate email + * @param emailCommunicator the class the queue will send messages with + * @param userPreferenceManager the class providing email preferences + * @param contentManager the content so we can access email templates + * @param logManager the logManager to log email sent + * @return an instance of the queue + */ + @Inject + @Provides + @Singleton + private static EmailManager getMessageCommunicationQueue(final PropertiesLoader properties, + final EmailCommunicator emailCommunicator, + final AbstractUserPreferenceManager userPreferenceManager, + final GitContentManager contentManager, + final ILogManager logManager) { + + Map globalTokens = Maps.newHashMap(); + globalTokens.put("sig", properties.getProperty(EMAIL_SIGNATURE)); + globalTokens.put("emailPreferencesURL", String.format("https://%s/account#emailpreferences", + properties.getProperty(HOST_NAME))); + globalTokens.put("myAssignmentsURL", String.format("https://%s/assignments", + properties.getProperty(HOST_NAME))); + String myQuizzesURL = String.format("https://%s/tests", properties.getProperty(HOST_NAME)); + globalTokens.put("myQuizzesURL", myQuizzesURL); + globalTokens.put("myTestsURL", myQuizzesURL); + globalTokens.put("myBookedEventsURL", String.format("https://%s/events?show_booked_only=true", + properties.getProperty(HOST_NAME))); + globalTokens.put("contactUsURL", String.format("https://%s/contact", + properties.getProperty(HOST_NAME))); + globalTokens.put("accountURL", String.format("https://%s/account", + properties.getProperty(HOST_NAME))); + globalTokens.put("siteBaseURL", String.format("https://%s", properties.getProperty(HOST_NAME))); + + if (null == emailCommunicationQueue) { + emailCommunicationQueue = new EmailManager(emailCommunicator, userPreferenceManager, properties, + contentManager, logManager, globalTokens); + log.info("Creating singleton of EmailCommunicationQueue"); + } + return emailCommunicationQueue; } - return userAuthenticationManager; - } - - /** - * This provides a singleton of the UserManager for various facades. - *
- * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. - * - * @param database the user persistence manager. - * @param questionManager IUserManager - * @param properties properties loader - * @param providersToRegister list of known providers. - * @param emailQueue so that we can send e-mails. - * @param temporaryUserCache to manage temporary anonymous users - * @param logManager so that we can log interesting user based events. - * @param mapperFacade for DO and DTO mapping. - * @param userAuthenticationManager Responsible for handling the various authentication functions. - * @param secondFactorManager For managing TOTP multifactor authentication. - * @param userPreferenceManager For managing user preferences. - * @return Content version controller with associated dependencies. - */ - @SuppressWarnings("checkstyle:ParameterNumber") - @Inject - @Provides - @Singleton - private IUserAccountManager getUserManager(final IUserDataManager database, final QuestionManager questionManager, - final PropertiesLoader properties, - final Map providersToRegister, - final EmailManager emailQueue, - final IAnonymousUserDataManager temporaryUserCache, - final ILogManager logManager, final MainObjectMapper mapperFacade, - final UserAuthenticationManager userAuthenticationManager, - final ISecondFactorAuthenticator secondFactorManager, - final AbstractUserPreferenceManager userPreferenceManager, - final SchoolListReader schoolListReader) { - if (null == userManager) { - userManager = new UserAccountManager(database, questionManager, properties, providersToRegister, - mapperFacade, emailQueue, temporaryUserCache, logManager, userAuthenticationManager, - secondFactorManager, userPreferenceManager, schoolListReader); - log.info("Creating singleton of UserManager"); + /** + * This provides a singleton of the UserManager for various facades. + *
+ * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. + * + * @param database the user persistence manager. + * @param properties properties loader + * @param providersToRegister list of known providers. + * @param emailQueue so that we can send e-mails. + * @return Content version controller with associated dependencies. + */ + @Inject + @Provides + @Singleton + private UserAuthenticationManager getUserAuthenticationManager( + final IUserDataManager database, final PropertiesLoader properties, + final Map providersToRegister, final EmailManager emailQueue) { + if (null == userAuthenticationManager) { + userAuthenticationManager = new UserAuthenticationManager(database, properties, providersToRegister, emailQueue); + log.info("Creating singleton of UserAuthenticationManager"); + } + + return userAuthenticationManager; } - return userManager; - } - - /** - * QuestionManager. - * Note: This has to be a singleton as the question manager keeps anonymous question attempts in memory. - * - * @param ds postgres data source - * @param objectMapper mapper - * @return a singleton for question persistence. - */ - @Inject - @Provides - @Singleton - private IQuestionAttemptManager getQuestionManager(final PostgresSqlDb ds, final ContentMapperUtils objectMapper) { - // this needs to be a singleton as it provides a temporary cache for anonymous question attempts. - if (null == questionPersistenceManager) { - questionPersistenceManager = new PgQuestionAttempts(ds, objectMapper); - log.info("Creating singleton of IQuestionAttemptManager"); + /** + * This provides a singleton of the UserManager for various facades. + *
+ * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. + * + * @param database the user persistence manager. + * @param questionManager IUserManager + * @param properties properties loader + * @param providersToRegister list of known providers. + * @param emailQueue so that we can send e-mails. + * @param temporaryUserCache to manage temporary anonymous users + * @param logManager so that we can log interesting user based events. + * @param mapperFacade for DO and DTO mapping. + * @param userAuthenticationManager Responsible for handling the various authentication functions. + * @param secondFactorManager For managing TOTP multifactor authentication. + * @param userPreferenceManager For managing user preferences. + * @return Content version controller with associated dependencies. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @Inject + @Provides + @Singleton + private IUserAccountManager getUserManager(final IUserDataManager database, final QuestionManager questionManager, + final PropertiesLoader properties, + final Map providersToRegister, + final EmailManager emailQueue, + final IAnonymousUserDataManager temporaryUserCache, + final ILogManager logManager, final MainObjectMapper mapperFacade, + final UserAuthenticationManager userAuthenticationManager, + final ISecondFactorAuthenticator secondFactorManager, + final AbstractUserPreferenceManager userPreferenceManager, + final SchoolListReader schoolListReader) { + if (null == userManager) { + userManager = new UserAccountManager(database, questionManager, properties, providersToRegister, + mapperFacade, emailQueue, temporaryUserCache, logManager, userAuthenticationManager, + secondFactorManager, userPreferenceManager, schoolListReader); + log.info("Creating singleton of UserManager"); + } + + return userManager; } - return questionPersistenceManager; - } - - /** - * This provides a singleton of the GroupManager. - *
- * Note: This needs to be a singleton as we register observers for groups. - * - * @param userGroupDataManager user group data manager - * @param userManager user manager - * @param gameManager game manager - * @param dtoMapper dtoMapper - * @return group manager - */ - @Inject - @Provides - @Singleton - private GroupManager getGroupManager(final IUserGroupPersistenceManager userGroupDataManager, - final UserAccountManager userManager, final GameManager gameManager, - final UserMapper dtoMapper) { - - if (null == groupManager) { - groupManager = new GroupManager(userGroupDataManager, userManager, gameManager, dtoMapper); - log.info("Creating singleton of GroupManager"); + /** + * QuestionManager. + * Note: This has to be a singleton as the question manager keeps anonymous question attempts in memory. + * + * @param ds postgres data source + * @param objectMapper mapper + * @return a singleton for question persistence. + */ + @Inject + @Provides + @Singleton + private IQuestionAttemptManager getQuestionManager(final PostgresSqlDb ds, final ContentMapperUtils objectMapper) { + // this needs to be a singleton as it provides a temporary cache for anonymous question attempts. + if (null == questionPersistenceManager) { + questionPersistenceManager = new PgQuestionAttempts(ds, objectMapper); + log.info("Creating singleton of IQuestionAttemptManager"); + } + + return questionPersistenceManager; } - return groupManager; - } + /** + * This provides a singleton of the GroupManager. + *
+ * Note: This needs to be a singleton as we register observers for groups. + * + * @param userGroupDataManager user group data manager + * @param userManager user manager + * @param gameManager game manager + * @param dtoMapper dtoMapper + * @return group manager + */ + @Inject + @Provides + @Singleton + private GroupManager getGroupManager(final IUserGroupPersistenceManager userGroupDataManager, + final UserAccountManager userManager, final GameManager gameManager, + final UserMapper dtoMapper) { + + if (null == groupManager) { + groupManager = new GroupManager(userGroupDataManager, userManager, gameManager, dtoMapper); + log.info("Creating singleton of GroupManager"); + } + + return groupManager; + } - @Inject - @Provides - @Singleton - private IGroupObserver getGroupObserver( - final EmailManager emailManager, final GroupManager groupManager, final UserAccountManager userManager, - final AssignmentManager assignmentManager, final QuizAssignmentManager quizAssignmentManager) { - if (null == groupObserver) { - groupObserver = - new GroupChangedService(emailManager, groupManager, userManager, assignmentManager, quizAssignmentManager); - log.info("Creating singleton of GroupObserver"); + @Inject + @Provides + @Singleton + private IGroupObserver getGroupObserver( + final EmailManager emailManager, final GroupManager groupManager, final UserAccountManager userManager, + final AssignmentManager assignmentManager, final QuizAssignmentManager quizAssignmentManager) { + if (null == groupObserver) { + groupObserver = + new GroupChangedService(emailManager, groupManager, userManager, assignmentManager, quizAssignmentManager); + log.info("Creating singleton of GroupObserver"); + } + return groupObserver; } - return groupObserver; - } - /** - * Get singleton of misuseMonitor. - *
- * Note: this has to be a singleton as it tracks (in memory) the number of misuses. - * - * @param emailManager so that the monitors can send e-mails. - * @param properties so that the monitors can look up email settings etc. - * @return gets the singleton of the misuse manager. - */ - @Inject - @Provides - @Singleton - private IMisuseMonitor getMisuseMonitor(final EmailManager emailManager, final PropertiesLoader properties) { - if (null == misuseMonitor) { - misuseMonitor = new InMemoryMisuseMonitor(); - log.info("Creating singleton of MisuseMonitor"); + /** + * Get singleton of misuseMonitor. + *
+ * Note: this has to be a singleton as it tracks (in memory) the number of misuses. + * + * @param emailManager so that the monitors can send e-mails. + * @param properties so that the monitors can look up email settings etc. + * @return gets the singleton of the misuse manager. + */ + @Inject + @Provides + @Singleton + private IMisuseMonitor getMisuseMonitor(final EmailManager emailManager, final PropertiesLoader properties) { + if (null == misuseMonitor) { + misuseMonitor = new InMemoryMisuseMonitor(); + log.info("Creating singleton of MisuseMonitor"); + + // TODO: We should automatically register all handlers that implement this interface using reflection? + // register handlers segue specific handlers + misuseMonitor.registerHandler(TokenOwnerLookupMisuseHandler.class.getSimpleName(), + new TokenOwnerLookupMisuseHandler(emailManager, properties)); - // TODO: We should automatically register all handlers that implement this interface using reflection? - // register handlers segue specific handlers - misuseMonitor.registerHandler(TokenOwnerLookupMisuseHandler.class.getSimpleName(), - new TokenOwnerLookupMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(GroupManagerLookupMisuseHandler.class.getSimpleName(), + new GroupManagerLookupMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(GroupManagerLookupMisuseHandler.class.getSimpleName(), - new GroupManagerLookupMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(EmailVerificationMisuseHandler.class.getSimpleName(), + new EmailVerificationMisuseHandler()); - misuseMonitor.registerHandler(EmailVerificationMisuseHandler.class.getSimpleName(), - new EmailVerificationMisuseHandler()); + misuseMonitor.registerHandler(EmailVerificationRequestMisuseHandler.class.getSimpleName(), + new EmailVerificationRequestMisuseHandler()); - misuseMonitor.registerHandler(EmailVerificationRequestMisuseHandler.class.getSimpleName(), - new EmailVerificationRequestMisuseHandler()); + misuseMonitor.registerHandler(PasswordResetByEmailMisuseHandler.class.getSimpleName(), + new PasswordResetByEmailMisuseHandler()); - misuseMonitor.registerHandler(PasswordResetByEmailMisuseHandler.class.getSimpleName(), - new PasswordResetByEmailMisuseHandler()); + misuseMonitor.registerHandler(PasswordResetByIPMisuseHandler.class.getSimpleName(), + new PasswordResetByIPMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(PasswordResetByIPMisuseHandler.class.getSimpleName(), - new PasswordResetByIPMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(TeacherPasswordResetMisuseHandler.class.getSimpleName(), + new TeacherPasswordResetMisuseHandler()); - misuseMonitor.registerHandler(TeacherPasswordResetMisuseHandler.class.getSimpleName(), - new TeacherPasswordResetMisuseHandler()); + misuseMonitor.registerHandler(RegistrationMisuseHandler.class.getSimpleName(), + new RegistrationMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(RegistrationMisuseHandler.class.getSimpleName(), - new RegistrationMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(SegueLoginByEmailMisuseHandler.class.getSimpleName(), + new SegueLoginByEmailMisuseHandler(properties)); - misuseMonitor.registerHandler(SegueLoginByEmailMisuseHandler.class.getSimpleName(), - new SegueLoginByEmailMisuseHandler(properties)); + misuseMonitor.registerHandler(SegueLoginByIPMisuseHandler.class.getSimpleName(), + new SegueLoginByIPMisuseHandler()); - misuseMonitor.registerHandler(SegueLoginByIPMisuseHandler.class.getSimpleName(), - new SegueLoginByIPMisuseHandler()); + misuseMonitor.registerHandler(LogEventMisuseHandler.class.getSimpleName(), + new LogEventMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(LogEventMisuseHandler.class.getSimpleName(), - new LogEventMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(QuestionAttemptMisuseHandler.class.getSimpleName(), + new QuestionAttemptMisuseHandler(properties)); - misuseMonitor.registerHandler(QuestionAttemptMisuseHandler.class.getSimpleName(), - new QuestionAttemptMisuseHandler(properties)); + misuseMonitor.registerHandler(AnonQuestionAttemptMisuseHandler.class.getSimpleName(), + new AnonQuestionAttemptMisuseHandler()); - misuseMonitor.registerHandler(AnonQuestionAttemptMisuseHandler.class.getSimpleName(), - new AnonQuestionAttemptMisuseHandler()); + misuseMonitor.registerHandler(IPQuestionAttemptMisuseHandler.class.getSimpleName(), + new IPQuestionAttemptMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(IPQuestionAttemptMisuseHandler.class.getSimpleName(), - new IPQuestionAttemptMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(UserSearchMisuseHandler.class.getSimpleName(), + new UserSearchMisuseHandler()); - misuseMonitor.registerHandler(UserSearchMisuseHandler.class.getSimpleName(), - new UserSearchMisuseHandler()); + misuseMonitor.registerHandler(SendEmailMisuseHandler.class.getSimpleName(), + new SendEmailMisuseHandler()); + } - misuseMonitor.registerHandler(SendEmailMisuseHandler.class.getSimpleName(), - new SendEmailMisuseHandler()); + return misuseMonitor; } - return misuseMonitor; - } - - @Provides - @Singleton - @Inject - public static MainObjectMapper getMainMapperInstance() { - return MainObjectMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static ContentMapper getContentMapperInstance() { - return ContentMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static UserMapper getUserMapperInstance() { - return UserMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static EventMapper getEventMapperInstance() { - return EventMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static MiscMapper getMiscMapperInstance() { - return MiscMapper.INSTANCE; - } - - /** - * Get the segue version currently running. Returns the value stored on the module if present or retrieves it from - * the properties if not. - * - * @return the segue version as a string or 'unknown' if it cannot be retrieved - */ - public static String getSegueVersion() { - if (SegueGuiceConfigurationModule.version != null) { - return SegueGuiceConfigurationModule.version; + @Provides + @Singleton + @Inject + public static MainObjectMapper getMainMapperInstance() { + return MainObjectMapper.INSTANCE; } - String version = "unknown"; - try { - Properties p = new Properties(); - try (InputStream is = SegueGuiceConfigurationModule.class.getResourceAsStream("/version.properties")) { - if (is != null) { - p.load(is); - version = p.getProperty("version", ""); + + @Provides + @Singleton + @Inject + public static ContentMapper getContentMapperInstance() { + return ContentMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static UserMapper getUserMapperInstance() { + return UserMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static EventMapper getEventMapperInstance() { + return EventMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static MiscMapper getMiscMapperInstance() { + return MiscMapper.INSTANCE; + } + + /** + * Get the segue version currently running. Returns the value stored on the module if present or retrieves it from + * the properties if not. + * + * @return the segue version as a string or 'unknown' if it cannot be retrieved + */ + public static String getSegueVersion() { + if (SegueGuiceConfigurationModule.version != null) { + return SegueGuiceConfigurationModule.version; } - } - } catch (Exception e) { - log.error(e.getMessage()); + String version = "unknown"; + try { + Properties p = new Properties(); + try (InputStream is = SegueGuiceConfigurationModule.class.getResourceAsStream("/version.properties")) { + if (is != null) { + p.load(is); + version = p.getProperty("version", ""); + } + } + } catch (Exception e) { + log.error(e.getMessage()); + } + SegueGuiceConfigurationModule.version = version; + return version; } - SegueGuiceConfigurationModule.version = version; - return version; - } - - /** - * Gets the instance of the postgres connection wrapper. - *
- * Note: This needs to be a singleton as it contains a connection pool. - * - * @param databaseUrl database to connect to. - * @param username port that the mongodb service is running on. - * @param password the name of the database to configure the wrapper to use. - * @return PostgresSqlDb db object preconfigured to work with the segue database. - */ - @Provides - @Singleton - @Inject - private static PostgresSqlDb getPostgresDB(@Named(Constants.POSTGRES_DB_URL) final String databaseUrl, - @Named(Constants.POSTGRES_DB_USER) final String username, - @Named(Constants.POSTGRES_DB_PASSWORD) final String password) { - - if (null == postgresDB) { - postgresDB = new PostgresSqlDb(databaseUrl, username, password); - log.info("Created Singleton of PostgresDb wrapper"); + + /** + * Gets the instance of the postgres connection wrapper. + *
+ * Note: This needs to be a singleton as it contains a connection pool. + * + * @param databaseUrl database to connect to. + * @param username port that the mongodb service is running on. + * @param password the name of the database to configure the wrapper to use. + * @return PostgresSqlDb db object preconfigured to work with the segue database. + */ + @Provides + @Singleton + @Inject + private static PostgresSqlDb getPostgresDB(@Named(Constants.POSTGRES_DB_URL) final String databaseUrl, + @Named(Constants.POSTGRES_DB_USER) final String username, + @Named(Constants.POSTGRES_DB_PASSWORD) final String password) { + + if (null == postgresDB) { + postgresDB = new PostgresSqlDb(databaseUrl, username, password); + log.info("Created Singleton of PostgresDb wrapper"); + } + + return postgresDB; } - return postgresDB; - } - - /** - * Gets the instance of the StatisticsManager. Note: this class is a hack and needs to be refactored.... It is - * currently only a singleton as it keeps a cache. - * - * @param userManager to query user information - * @param logManager to query Log information - * @param schoolManager to query School information - * @param contentManager to query live version information - * @param contentIndex index string for current content version - * @param groupManager so that we can see how many groups we have site wide. - * @param questionManager so that we can see how many questions were answered. - * @param contentSummarizerService to produce content summary objects - * @param userStreaksManager to notify users when their answer streak changes - * @return stats manager - */ - @SuppressWarnings("checkstyle:ParameterNumber") - @Provides - @Singleton - @Inject - private static StatisticsManager getStatsManager(final UserAccountManager userManager, - final ILogManager logManager, final SchoolListReader schoolManager, - final GitContentManager contentManager, - @Named(CONTENT_INDEX) final String contentIndex, - final GroupManager groupManager, - final QuestionManager questionManager, - final ContentSummarizerService contentSummarizerService, - final IUserStreaksManager userStreaksManager) { - - if (null == statsManager) { - statsManager = new StatisticsManager(userManager, logManager, schoolManager, contentManager, contentIndex, - groupManager, questionManager, contentSummarizerService, userStreaksManager); - log.info("Created Singleton of Statistics Manager"); + /** + * Gets the instance of the StatisticsManager. Note: this class is a hack and needs to be refactored.... It is + * currently only a singleton as it keeps a cache. + * + * @param userManager to query user information + * @param logManager to query Log information + * @param schoolManager to query School information + * @param contentManager to query live version information + * @param contentIndex index string for current content version + * @param groupManager so that we can see how many groups we have site wide. + * @param questionManager so that we can see how many questions were answered. + * @param contentSummarizerService to produce content summary objects + * @param userStreaksManager to notify users when their answer streak changes + * @return stats manager + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @Provides + @Singleton + @Inject + private static StatisticsManager getStatsManager(final UserAccountManager userManager, + final ILogManager logManager, final SchoolListReader schoolManager, + final GitContentManager contentManager, + @Named(CONTENT_INDEX) final String contentIndex, + final GroupManager groupManager, + final QuestionManager questionManager, + final ContentSummarizerService contentSummarizerService, + final IUserStreaksManager userStreaksManager) { + + if (null == statsManager) { + statsManager = new StatisticsManager(userManager, logManager, schoolManager, contentManager, contentIndex, + groupManager, questionManager, contentSummarizerService, userStreaksManager); + log.info("Created Singleton of Statistics Manager"); + } + + return statsManager; } - return statsManager; - } - - static final String CRON_STRING_0200_DAILY = "0 0 2 * * ?"; - static final String CRON_STRING_0230_DAILY = "0 30 2 * * ?"; - static final String CRON_STRING_0700_DAILY = "0 0 7 * * ?"; - static final String CRON_STRING_2000_DAILY = "0 0 20 * * ?"; - static final String CRON_STRING_HOURLY = "0 0 * ? * * *"; - static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0 0/4 ? * * *"; - static final String CRON_GROUP_NAME_SQL_MAINTENANCE = "SQLMaintenance"; - static final String CRON_GROUP_NAME_JAVA_JOB = "JavaJob"; - - @Provides - @Singleton - @Inject - private static SegueJobService getSegueJobService(final PropertiesLoader properties, final PostgresSqlDb database) { - if (null == segueJobService) { - String mailjetKey = properties.getProperty(MAILJET_API_KEY); - String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); - String eventPrePostEmails = properties.getProperty(EVENT_PRE_POST_EMAILS); - boolean eventPrePostEmailsEnabled = - null != eventPrePostEmails && !eventPrePostEmails.isEmpty() && Boolean.parseBoolean(eventPrePostEmails); - - SegueScheduledJob piiSqlJob = new SegueScheduledDatabaseScriptJob( - "PIIDeleteScheduledJob", - CRON_GROUP_NAME_SQL_MAINTENANCE, - "SQL scheduled job that deletes PII", - CRON_STRING_0200_DAILY, "db_scripts/scheduled/pii-delete-task.sql"); - - SegueScheduledJob cleanUpOldAnonymousUsers = new SegueScheduledDatabaseScriptJob( - "cleanAnonymousUsers", - CRON_GROUP_NAME_SQL_MAINTENANCE, - "SQL scheduled job that deletes old AnonymousUsers", - CRON_STRING_0230_DAILY, "db_scripts/scheduled/anonymous-user-clean-up.sql"); - - SegueScheduledJob cleanUpExpiredReservations = new SegueScheduledDatabaseScriptJob( - "cleanUpExpiredReservations", - CRON_GROUP_NAME_SQL_MAINTENANCE, - "SQL scheduled job that deletes expired reservations for the event booking system", - CRON_STRING_0700_DAILY, "db_scripts/scheduled/expired-reservations-clean-up.sql"); - - SegueScheduledJob deleteEventAdditionalBookingInformation = SegueScheduledJob.createCustomJob( - "deleteEventAdditionalBookingInformation", - CRON_GROUP_NAME_JAVA_JOB, - "Delete event additional booking information a given period after an event has taken place", - CRON_STRING_0700_DAILY, - Maps.newHashMap(), - new DeleteEventAdditionalBookingInformationJob() - ); - - SegueScheduledJob deleteEventAdditionalBookingInformationOneYearJob = SegueScheduledJob.createCustomJob( - "deleteEventAdditionalBookingInformationOneYear", - CRON_GROUP_NAME_JAVA_JOB, - "Delete event additional booking information a year after an event has taken place if not already removed", - CRON_STRING_0700_DAILY, - Maps.newHashMap(), - new DeleteEventAdditionalBookingInformationOneYearJob() - ); - - SegueScheduledJob eventReminderEmail = SegueScheduledJob.createCustomJob( - "eventReminderEmail", - CRON_GROUP_NAME_JAVA_JOB, - "Send scheduled reminder emails to events", - CRON_STRING_0700_DAILY, - Maps.newHashMap(), - new EventReminderEmailJob() - ); - - SegueScheduledJob eventFeedbackEmail = SegueScheduledJob.createCustomJob( - "eventFeedbackEmail", - CRON_GROUP_NAME_JAVA_JOB, - "Send scheduled feedback emails to events", - CRON_STRING_2000_DAILY, - Maps.newHashMap(), - new EventFeedbackEmailJob() - ); - - SegueScheduledJob scheduledAssignmentsEmail = SegueScheduledJob.createCustomJob( - "scheduledAssignmentsEmail", - CRON_GROUP_NAME_JAVA_JOB, - "Send scheduled assignment notification emails to groups", - CRON_STRING_HOURLY, - Maps.newHashMap(), - new ScheduledAssignmentsEmailJob() - ); - - SegueScheduledJob syncMailjetUsers = new SegueScheduledSyncMailjetUsersJob( - "syncMailjetUsersJob", - CRON_GROUP_NAME_JAVA_JOB, - "Sync users to mailjet", - CRON_STRING_EVERY_FOUR_HOURS); - - List configuredScheduledJobs = new ArrayList<>(Arrays.asList( - piiSqlJob, - cleanUpOldAnonymousUsers, - cleanUpExpiredReservations, - deleteEventAdditionalBookingInformation, - deleteEventAdditionalBookingInformationOneYearJob, - scheduledAssignmentsEmail - )); - - // Simply removing jobs from configuredScheduledJobs won't de-register them if they - // are currently configured, so the constructor takes a list of jobs to remove too. - List scheduledJobsToRemove = new ArrayList<>(); - - if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { - configuredScheduledJobs.add(syncMailjetUsers); - } else { - scheduledJobsToRemove.add(syncMailjetUsers); - } - - if (eventPrePostEmailsEnabled) { - configuredScheduledJobs.add(eventReminderEmail); - configuredScheduledJobs.add(eventFeedbackEmail); - } else { - scheduledJobsToRemove.add(eventReminderEmail); - scheduledJobsToRemove.add(eventFeedbackEmail); - } - segueJobService = new SegueJobService(database, configuredScheduledJobs, scheduledJobsToRemove); + static final String CRON_STRING_0200_DAILY = "0 0 2 * * ?"; + static final String CRON_STRING_0230_DAILY = "0 30 2 * * ?"; + static final String CRON_STRING_0700_DAILY = "0 0 7 * * ?"; + static final String CRON_STRING_2000_DAILY = "0 0 20 * * ?"; + static final String CRON_STRING_HOURLY = "0 0 * ? * * *"; + static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0 0/4 ? * * *"; + static final String CRON_GROUP_NAME_SQL_MAINTENANCE = "SQLMaintenance"; + static final String CRON_GROUP_NAME_JAVA_JOB = "JavaJob"; + + @Provides + @Singleton + @Inject + private static SegueJobService getSegueJobService(final PropertiesLoader properties, final PostgresSqlDb database) { + if (null == segueJobService) { + String mailjetKey = properties.getProperty(MAILJET_API_KEY); + String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); + String eventPrePostEmails = properties.getProperty(EVENT_PRE_POST_EMAILS); + boolean eventPrePostEmailsEnabled = + null != eventPrePostEmails && !eventPrePostEmails.isEmpty() && Boolean.parseBoolean(eventPrePostEmails); + + SegueScheduledJob piiSqlJob = new SegueScheduledDatabaseScriptJob( + "PIIDeleteScheduledJob", + CRON_GROUP_NAME_SQL_MAINTENANCE, + "SQL scheduled job that deletes PII", + CRON_STRING_0200_DAILY, "db_scripts/scheduled/pii-delete-task.sql"); + + SegueScheduledJob cleanUpOldAnonymousUsers = new SegueScheduledDatabaseScriptJob( + "cleanAnonymousUsers", + CRON_GROUP_NAME_SQL_MAINTENANCE, + "SQL scheduled job that deletes old AnonymousUsers", + CRON_STRING_0230_DAILY, "db_scripts/scheduled/anonymous-user-clean-up.sql"); + + SegueScheduledJob cleanUpExpiredReservations = new SegueScheduledDatabaseScriptJob( + "cleanUpExpiredReservations", + CRON_GROUP_NAME_SQL_MAINTENANCE, + "SQL scheduled job that deletes expired reservations for the event booking system", + CRON_STRING_0700_DAILY, "db_scripts/scheduled/expired-reservations-clean-up.sql"); + + SegueScheduledJob deleteEventAdditionalBookingInformation = SegueScheduledJob.createCustomJob( + "deleteEventAdditionalBookingInformation", + CRON_GROUP_NAME_JAVA_JOB, + "Delete event additional booking information a given period after an event has taken place", + CRON_STRING_0700_DAILY, + Maps.newHashMap(), + new DeleteEventAdditionalBookingInformationJob() + ); + + SegueScheduledJob deleteEventAdditionalBookingInformationOneYearJob = SegueScheduledJob.createCustomJob( + "deleteEventAdditionalBookingInformationOneYear", + CRON_GROUP_NAME_JAVA_JOB, + "Delete event additional booking information a year after an event has taken place if not already removed", + CRON_STRING_0700_DAILY, + Maps.newHashMap(), + new DeleteEventAdditionalBookingInformationOneYearJob() + ); + + SegueScheduledJob eventReminderEmail = SegueScheduledJob.createCustomJob( + "eventReminderEmail", + CRON_GROUP_NAME_JAVA_JOB, + "Send scheduled reminder emails to events", + CRON_STRING_0700_DAILY, + Maps.newHashMap(), + new EventReminderEmailJob() + ); + + SegueScheduledJob eventFeedbackEmail = SegueScheduledJob.createCustomJob( + "eventFeedbackEmail", + CRON_GROUP_NAME_JAVA_JOB, + "Send scheduled feedback emails to events", + CRON_STRING_2000_DAILY, + Maps.newHashMap(), + new EventFeedbackEmailJob() + ); + + SegueScheduledJob scheduledAssignmentsEmail = SegueScheduledJob.createCustomJob( + "scheduledAssignmentsEmail", + CRON_GROUP_NAME_JAVA_JOB, + "Send scheduled assignment notification emails to groups", + CRON_STRING_HOURLY, + Maps.newHashMap(), + new ScheduledAssignmentsEmailJob() + ); + + SegueScheduledJob syncMailjetUsers = new SegueScheduledSyncMailjetUsersJob( + "syncMailjetUsersJob", + CRON_GROUP_NAME_JAVA_JOB, + "Sync users to mailjet", + CRON_STRING_EVERY_FOUR_HOURS); + + List configuredScheduledJobs = new ArrayList<>(Arrays.asList( + piiSqlJob, + cleanUpOldAnonymousUsers, + cleanUpExpiredReservations, + deleteEventAdditionalBookingInformation, + deleteEventAdditionalBookingInformationOneYearJob, + scheduledAssignmentsEmail + )); + + // Simply removing jobs from configuredScheduledJobs won't de-register them if they + // are currently configured, so the constructor takes a list of jobs to remove too. + List scheduledJobsToRemove = new ArrayList<>(); + + if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { + configuredScheduledJobs.add(syncMailjetUsers); + } else { + scheduledJobsToRemove.add(syncMailjetUsers); + } + + if (eventPrePostEmailsEnabled) { + configuredScheduledJobs.add(eventReminderEmail); + configuredScheduledJobs.add(eventFeedbackEmail); + } else { + scheduledJobsToRemove.add(eventReminderEmail); + scheduledJobsToRemove.add(eventFeedbackEmail); + } + segueJobService = new SegueJobService(database, configuredScheduledJobs, scheduledJobsToRemove); + } + + return segueJobService; } - return segueJobService; - } - - @Provides - @Singleton - @Inject - private static IExternalAccountManager getExternalAccountManager(final PropertiesLoader properties, - final PostgresSqlDb database) { - - if (null == externalAccountManager) { - String mailjetKey = properties.getProperty(MAILJET_API_KEY); - String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); - - if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { - // If MailJet is configured, initialise the sync: - IExternalAccountDataManager externalAccountDataManager = new PgExternalAccountPersistenceManager(database); - MailJetApiClientWrapper mailJetApiClientWrapper = new MailJetApiClientWrapper(mailjetKey, mailjetSecret, - properties.getProperty(MAILJET_NEWS_LIST_ID), properties.getProperty(MAILJET_EVENTS_LIST_ID), - properties.getProperty(MAILJET_LEGAL_LIST_ID)); - - log.info("Created singleton of ExternalAccountManager."); - externalAccountManager = new ExternalAccountManager(mailJetApiClientWrapper, externalAccountDataManager); - } else { - // Else warn and initialise a placeholder that always throws an error if used: - log.warn("Created stub of ExternalAccountManager since external provider not configured."); - externalAccountManager = new StubExternalAccountManager(); - } + @Provides + @Singleton + @Inject + private static IExternalAccountManager getExternalAccountManager(final PropertiesLoader properties, + final PostgresSqlDb database) { + + if (null == externalAccountManager) { + String mailjetKey = properties.getProperty(MAILJET_API_KEY); + String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); + + if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { + // If MailJet is configured, initialise the sync: + IExternalAccountDataManager externalAccountDataManager = new PgExternalAccountPersistenceManager(database); + MailJetApiClientWrapper mailJetApiClientWrapper = new MailJetApiClientWrapper(mailjetKey, mailjetSecret, + properties.getProperty(MAILJET_NEWS_LIST_ID), properties.getProperty(MAILJET_EVENTS_LIST_ID), + properties.getProperty(MAILJET_LEGAL_LIST_ID)); + + log.info("Created singleton of ExternalAccountManager."); + externalAccountManager = new ExternalAccountManager(mailJetApiClientWrapper, externalAccountDataManager); + } else { + // Else warn and initialise a placeholder that always throws an error if used: + log.warn("Created stub of ExternalAccountManager since external provider not configured."); + externalAccountManager = new StubExternalAccountManager(); + } + } + return externalAccountManager; } - return externalAccountManager; - } - - /** - * Gets a Game persistence manager. - *
- * This needs to be a singleton as it maintains temporary boards in memory. - * - * @param database the database that persists gameboards. - * @param contentManager api that the game manager can use for content resolution. - * @param mapper an instance of an auto mapper for translating gameboard DOs and DTOs efficiently. - * @param objectMapper a mapper to allow content to be resolved. - * @param uriManager so that we can create content that is aware of its own location - * @return Game persistence manager object. - */ - @Inject - @Provides - @Singleton - private static GameboardPersistenceManager getGameboardPersistenceManager( - final PostgresSqlDb database, - final GitContentManager contentManager, - final MainObjectMapper mapper, - final ObjectMapper objectMapper, - final URIManager uriManager - ) { - if (null == gameboardPersistenceManager) { - gameboardPersistenceManager = new GameboardPersistenceManager(database, contentManager, mapper, - objectMapper, uriManager); - log.info("Creating Singleton of GameboardPersistenceManager"); + + /** + * Gets a Game persistence manager. + *
+ * This needs to be a singleton as it maintains temporary boards in memory. + * + * @param database the database that persists gameboards. + * @param contentManager api that the game manager can use for content resolution. + * @param mapper an instance of an auto mapper for translating gameboard DOs and DTOs efficiently. + * @param objectMapper a mapper to allow content to be resolved. + * @param uriManager so that we can create content that is aware of its own location + * @return Game persistence manager object. + */ + @Inject + @Provides + @Singleton + private static GameboardPersistenceManager getGameboardPersistenceManager( + final PostgresSqlDb database, + final GitContentManager contentManager, + final MainObjectMapper mapper, + final ObjectMapper objectMapper, + final URIManager uriManager + ) { + if (null == gameboardPersistenceManager) { + gameboardPersistenceManager = new GameboardPersistenceManager(database, contentManager, mapper, + objectMapper, uriManager); + log.info("Creating Singleton of GameboardPersistenceManager"); + } + + return gameboardPersistenceManager; } - return gameboardPersistenceManager; - } - - /** - * Gets an assignment manager. - *
- * This needs to be a singleton because operations like emailing are run for each IGroupObserver, the - * assignment manager should only be one observer. - * - * @param assignmentPersistenceManager to save assignments - * @param groupManager to allow communication with the group manager. - * @param emailService email service - * @param gameManager the game manager object - * @param properties properties loader for the service's hostname - * @return Assignment manager object. - */ - @Inject - @Provides - @Singleton - private static AssignmentManager getAssignmentManager( - final IAssignmentPersistenceManager assignmentPersistenceManager, final GroupManager groupManager, - final EmailService emailService, final GameManager gameManager, final PropertiesLoader properties) { - if (null == assignmentManager) { - assignmentManager = - new AssignmentManager(assignmentPersistenceManager, groupManager, emailService, gameManager, properties); - log.info("Creating Singleton AssignmentManager"); + /** + * Gets an assignment manager. + *
+ * This needs to be a singleton because operations like emailing are run for each IGroupObserver, the + * assignment manager should only be one observer. + * + * @param assignmentPersistenceManager to save assignments + * @param groupManager to allow communication with the group manager. + * @param emailService email service + * @param gameManager the game manager object + * @param properties properties loader for the service's hostname + * @return Assignment manager object. + */ + @Inject + @Provides + @Singleton + private static AssignmentManager getAssignmentManager( + final IAssignmentPersistenceManager assignmentPersistenceManager, final GroupManager groupManager, + final EmailService emailService, final GameManager gameManager, final PropertiesLoader properties) { + if (null == assignmentManager) { + assignmentManager = + new AssignmentManager(assignmentPersistenceManager, groupManager, emailService, gameManager, properties); + log.info("Creating Singleton AssignmentManager"); + } + return assignmentManager; } - return assignmentManager; - } - - /** - * Gets an instance of the symbolic question validator. - * - * @param properties properties loader to get the symbolic validator host - * @return IsaacSymbolicValidator preconfigured to work with the specified checker. - */ - @Provides - @Singleton - @Inject - private static IsaacSymbolicValidator getSymbolicValidator(final PropertiesLoader properties) { - - return new IsaacSymbolicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), - properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); - } - - /** - * Gets an instance of the symbolic logic question validator. - * - * @param properties properties loader to get the symbolic logic validator host - * @return IsaacSymbolicLogicValidator preconfigured to work with the specified checker. - */ - @Provides - @Singleton - @Inject - private static IsaacSymbolicLogicValidator getSymbolicLogicValidator(final PropertiesLoader properties) { - - return new IsaacSymbolicLogicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), - properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); - } - - /** - * This provides a singleton of the SchoolListReader for use by segue backed applications.. - *
- * We want this to be a singleton as otherwise it may not be threadsafe for loading into same SearchProvider. - * - * @param provider The search provider. - * @return schoolList reader - */ - @Inject - @Provides - @Singleton - private SchoolListReader getSchoolListReader(final ISearchProvider provider) { - if (null == schoolListReader) { - schoolListReader = new SchoolListReader(provider); - log.info("Creating singleton of SchoolListReader"); + + /** + * Gets an instance of the symbolic question validator. + * + * @param properties properties loader to get the symbolic validator host + * @return IsaacSymbolicValidator preconfigured to work with the specified checker. + */ + @Provides + @Singleton + @Inject + private static IsaacSymbolicValidator getSymbolicValidator(final PropertiesLoader properties) { + + return new IsaacSymbolicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), + properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); } - return schoolListReader; - } - - /** - * Utility method to make the syntax of property bindings clearer. - * - * @param propertyLabel Key for a given property - * @param propertyLoader property loader to use - */ - private void bindConstantToProperty(final String propertyLabel, final PropertiesLoader propertyLoader) { - bindConstant().annotatedWith(Names.named(propertyLabel)).to(propertyLoader.getProperty(propertyLabel)); - } - - /** - * Utility method to get a pre-generated reflections class for the uk.ac.cam.cl.dtg.segue package. - * - * @param pkg class name to use as key - * @return reflections. - */ - public static Set> getPackageClasses(final String pkg) { - return classesByPackage.computeIfAbsent(pkg, key -> { - log.info(String.format("Caching reflections scan on '%s'", key)); - return getClasses(key); - }); - } - - /** - * Gets the segue classes that should be registered as context listeners. - * - * @return the list of context listener classes (these should all be singletons). - */ - public static Collection> getRegisteredContextListenerClasses() { - - if (null == contextListeners) { - contextListeners = Lists.newArrayList(); - - Set> subTypes = - getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue"), ServletContextListener.class); - - Set> etlSubTypes = - getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue.etl"), ServletContextListener.class); - - subTypes.removeAll(etlSubTypes); - - for (Class contextListener : subTypes) { - contextListeners.add(contextListener); - log.info("Registering context listener class {}", contextListener.getCanonicalName()); - } + + /** + * Gets an instance of the symbolic logic question validator. + * + * @param properties properties loader to get the symbolic logic validator host + * @return IsaacSymbolicLogicValidator preconfigured to work with the specified checker. + */ + @Provides + @Singleton + @Inject + private static IsaacSymbolicLogicValidator getSymbolicLogicValidator(final PropertiesLoader properties) { + + return new IsaacSymbolicLogicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), + properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); } - return contextListeners; - } - - @Override - public void contextInitialized(final ServletContextEvent sce) { - // nothing needed - } - - @Override - public void contextDestroyed(final ServletContextEvent sce) { - // Close all resources we hold. - log.info("Segue Config Module notified of shutdown. Releasing resources"); - try { - elasticSearchClient.close(); - elasticSearchClient = null; - } catch (IOException e) { - log.error("Error releasing Elasticsearch client", e); + /** + * This provides a singleton of the SchoolListReader for use by segue backed applications.. + *
+ * We want this to be a singleton as otherwise it may not be threadsafe for loading into same SearchProvider. + * + * @param provider The search provider. + * @return schoolList reader + */ + @Inject + @Provides + @Singleton + private SchoolListReader getSchoolListReader(final ISearchProvider provider) { + if (null == schoolListReader) { + schoolListReader = new SchoolListReader(provider); + log.info("Creating singleton of SchoolListReader"); + } + return schoolListReader; + } + + /** + * Utility method to make the syntax of property bindings clearer. + * + * @param propertyLabel Key for a given property + * @param propertyLoader property loader to use + */ + private void bindConstantToProperty(final String propertyLabel, final PropertiesLoader propertyLoader) { + bindConstant().annotatedWith(Names.named(propertyLabel)).to(propertyLoader.getProperty(propertyLabel)); + } + + /** + * Utility method to get a pre-generated reflections class for the uk.ac.cam.cl.dtg.segue package. + * + * @param pkg class name to use as key + * @return reflections. + */ + public static Set> getPackageClasses(final String pkg) { + return classesByPackage.computeIfAbsent(pkg, key -> { + log.info(String.format("Caching reflections scan on '%s'", key)); + return getClasses(key); + }); + } + + /** + * Gets the segue classes that should be registered as context listeners. + * + * @return the list of context listener classes (these should all be singletons). + */ + public static Collection> getRegisteredContextListenerClasses() { + + if (null == contextListeners) { + contextListeners = Lists.newArrayList(); + + Set> subTypes = + getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue"), ServletContextListener.class); + + Set> etlSubTypes = + getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue.etl"), ServletContextListener.class); + + subTypes.removeAll(etlSubTypes); + + for (Class contextListener : subTypes) { + contextListeners.add(contextListener); + log.info("Registering context listener class {}", contextListener.getCanonicalName()); + } + } + + return contextListeners; + } + + @Override + public void contextInitialized(final ServletContextEvent sce) { + // nothing needed + } + + @Override + public void contextDestroyed(final ServletContextEvent sce) { + // Close all resources we hold. + log.info("Segue Config Module notified of shutdown. Releasing resources"); + try { + elasticSearchClient.close(); + elasticSearchClient = null; + } catch (IOException e) { + log.error("Error releasing Elasticsearch client", e); + } + + postgresDB.close(); + postgresDB = null; + } + + /** + * Factory method for providing a single Guice Injector class. + * + * @return a Guice Injector configured with this SegueGuiceConfigurationModule. + */ + public static synchronized Injector getGuiceInjector() { + if (null == injector) { + injector = Guice.createInjector(new SegueGuiceConfigurationModule()); + } + return injector; } - postgresDB.close(); - postgresDB = null; - } - - /** - * Factory method for providing a single Guice Injector class. - * - * @return a Guice Injector configured with this SegueGuiceConfigurationModule. - */ - public static synchronized Injector getGuiceInjector() { - if (null == injector) { - injector = Guice.createInjector(new SegueGuiceConfigurationModule()); + @Provides + @Singleton + public static Clock getDefaultClock() { + return Clock.systemUTC(); } - return injector; - } - - @Provides - @Singleton - public static Clock getDefaultClock() { - return Clock.systemUTC(); - } } From c15160c013c5446eee940385cb733d457291120f Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 12:03:40 +0200 Subject: [PATCH 13/22] PATCH 19 --- .../managers/ExternalAccountManagerTest.java | 34 +++++++++++++++++-- ...ExternalAccountPersistenceManagerTest.java | 10 +++--- .../email/MailJetApiClientWrapperTest.java | 5 +++ 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java index c658ca2d98..b856d329fc 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java @@ -7,6 +7,7 @@ import static org.easymock.EasyMock.verify; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.mailjet.client.errors.MailjetClientCommunicationException; import com.mailjet.client.errors.MailjetException; import java.util.List; import org.json.JSONObject; @@ -123,7 +124,10 @@ void synchroniseChangedUsers_deletedUser() throws SegueDatabaseException, Mailje } @Test - void synchroniseChangedUsers_mailjetException() throws SegueDatabaseException, MailjetException { + void synchroniseChangedUsers_mailjetException() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Regular MailjetException is caught and logged, not re-thrown + // Only MailjetClientCommunicationException causes the method to throw UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, EmailVerificationStatus.VERIFIED, true, false, "GCSE" @@ -131,11 +135,35 @@ void synchroniseChangedUsers_mailjetException() throws SegueDatabaseException, M List changedUsers = List.of(userChanges); expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")).andThrow(new MailjetException("Mailjet error")); + expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")) + .andThrow(new MailjetException("Mailjet error")); replay(mockDatabase, mailjetApi); - assertThrows(ExternalAccountSynchronisationException.class, () -> externalAccountManager.synchroniseChangedUsers()); + // This should NOT throw - regular MailjetException is caught and logged + externalAccountManager.synchroniseChangedUsers(); + + verify(mockDatabase, mailjetApi); + } + + @Test + void synchroniseChangedUsers_communicationException() throws SegueDatabaseException, MailjetException { + // MailjetClientCommunicationException should cause the method to throw + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")) + .andThrow(new MailjetClientCommunicationException("Communication error")); + + replay(mockDatabase, mailjetApi); + + // Communication exceptions should be re-thrown + assertThrows(ExternalAccountSynchronisationException.class, + () -> externalAccountManager.synchroniseChangedUsers()); verify(mockDatabase, mailjetApi); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java index 8d3c10c70c..e076c1f3cb 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java @@ -31,10 +31,9 @@ void setUp() { @Test void buildUserExternalAccountChanges_ShouldParseRegisteredContextsCorrectly() throws Exception { // Arrange - String registeredContextsJson = "{\"stage\": \"gcse\", \"examBoard\": \"ocr\"}"; + // The method expects a JSON ARRAY (from PostgreSQL array_to_json(JSONB[])) + String registeredContextsJson = "[{\"stage\": \"gcse\", \"examBoard\": \"ocr\"}]"; - // Mock ResultSet behavior - expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContextsJson); // Expect this call once expect(mockResultSet.getLong("id")).andReturn(1L); expect(mockResultSet.getString("provider_user_identifier")).andReturn("providerId"); expect(mockResultSet.getString("email")).andReturn("test@example.com"); @@ -43,7 +42,10 @@ void buildUserExternalAccountChanges_ShouldParseRegisteredContextsCorrectly() th expect(mockResultSet.getBoolean("deleted")).andReturn(false); expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); // wasNull() after getBoolean("news_emails") expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); // wasNull() after getBoolean("events_emails") + expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContextsJson); replay(mockResultSet); @@ -69,6 +71,6 @@ void buildUserExternalAccountChanges_ShouldParseRegisteredContextsCorrectly() th assertFalse(result.allowsEventsEmails()); // Verify JSON parsing and stage extraction - assertEquals("gcse", result.getStage()); // Ensure "stage" is correctly extracted from JSON + assertEquals("gcse".toUpperCase(), result.getStage()); } } \ No newline at end of file diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java index f94e25f30f..bec4934c00 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java @@ -45,6 +45,9 @@ void getAccountByIdOrEmail_WithValidInput_ShouldReturnAccount() throws MailjetEx mockData.put(mockAccount); expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(mockResponse); + // The code checks getStatus() twice: once for 404, once for 200 + expect(mockResponse.getStatus()).andReturn(200); + expect(mockResponse.getStatus()).andReturn(200); expect(mockResponse.getTotal()).andReturn(1); expect(mockResponse.getData()).andReturn(mockData); @@ -70,6 +73,8 @@ void addNewUserOrGetUserIfExists_WithNewEmail_ShouldReturnNewId() throws Mailjet mockData.put(mockUser); expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); + // The code checks getStatus() first, then getData() + expect(mockResponse.getStatus()).andReturn(201); expect(mockResponse.getData()).andReturn(mockData); replay(mockMailjetClient, mockResponse); From e429eaef40588f3769f32e555d257c9223697bf1 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 12:32:48 +0200 Subject: [PATCH 14/22] PATCH 19 --- .../api/managers/ExternalAccountManager.java | 748 +++--- .../SegueGuiceConfigurationModule.java | 2109 +++++++++-------- .../util/email/MailJetApiClientWrapper.java | 566 ++--- 3 files changed, 1723 insertions(+), 1700 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index 85d56a7802..66dbec9c50 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -19,9 +19,7 @@ import com.mailjet.client.errors.MailjetClientCommunicationException; import com.mailjet.client.errors.MailjetException; import com.mailjet.client.errors.MailjetRateLimitException; - import java.util.List; - import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,379 +31,379 @@ import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; public class ExternalAccountManager implements IExternalAccountManager { - private static final Logger log = LoggerFactory.getLogger(ExternalAccountManager.class); - - private final IExternalAccountDataManager database; - private final MailJetApiClientWrapper mailjetApi; - - /** - * Synchronise account settings, email preferences and verification status with third party providers. - *
- * Currently this class is highly specialised for synchronising with MailJet. - * - * @param mailjetApi - to enable updates on MailJet - * @param database - to persist external identifiers and to record sync success. - */ - public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IExternalAccountDataManager database) { - this.database = database; - this.mailjetApi = mailjetApi; - } - - /** - * Synchronise account settings and data with external providers. - *
- * Whilst the actions this method takes are mostly idempotent, it should not be run simultaneously with itself. - * - * @throws ExternalAccountSynchronisationException on unrecoverable errors with external providers. - */ - @Override - public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { - log.info("Starting Mailjet synchronization process"); - - List userRecordsToUpdate; - try { - userRecordsToUpdate = database.getRecentlyChangedRecords(); - log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); - } catch (SegueDatabaseException e) { - log.error("Database error whilst collecting users whose details have changed", e); - throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); - } - - if (userRecordsToUpdate.isEmpty()) { - log.info("No users to synchronize"); - return; - } - - SyncMetrics metrics = new SyncMetrics(); - - for (UserExternalAccountChanges userRecord : userRecordsToUpdate) { - Long userId = userRecord.getUserId(); - - try { - processUserSync(userRecord, metrics); - metrics.incrementSuccess(); - - } catch (SegueDatabaseException e) { - metrics.incrementDatabaseError(); - log.error("Database error storing Mailjet update for user ID: {}", userId, e); - // Continue processing other users - - } catch (MailjetClientCommunicationException e) { - metrics.incrementCommunicationError(); - log.error("Failed to communicate with Mailjet while processing user ID: {}", userId, e); - throw new ExternalAccountSynchronisationException("Failed to connect to Mailjet" + e); - - } catch (MailjetRateLimitException e) { - metrics.incrementRateLimitError(); - log.warn("Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit", - userId, metrics.getSuccessCount()); - throw new ExternalAccountSynchronisationException( - "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); - - } catch (MailjetException e) { - metrics.incrementMailjetError(); - log.error("Mailjet API error while processing user ID: {}. Continuing with next user", userId, e); - - } catch (Exception e) { - metrics.incrementUnexpectedError(); - log.error("Unexpected error processing user ID: {}", userId, e); - } - } - - logSyncSummary(metrics, userRecordsToUpdate.size()); - } - - /** - * Process synchronization for a single user. - */ - private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics metrics) - throws SegueDatabaseException, MailjetException { - - Long userId = userRecord.getUserId(); - String accountEmail = userRecord.getAccountEmail(); - - if (accountEmail == null || accountEmail.trim().isEmpty()) { - log.warn("User ID {} has null or empty email address. Skipping", userId); - metrics.incrementSkipped(); - return; - } - - boolean accountEmailDeliveryFailed = - EmailVerificationStatus.DELIVERY_FAILED.equals(userRecord.getEmailVerificationStatus()); - String mailjetId = userRecord.getProviderUserId(); - - if (mailjetId != null && !mailjetId.trim().isEmpty()) { - handleExistingMailjetUser(mailjetId, userRecord, accountEmail, accountEmailDeliveryFailed, metrics); - } else { - handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); - } - - database.updateProviderLastUpdated(userId); - } - - /** - * Handle synchronization for users that already exist in Mailjet. - */ - private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChanges userRecord, - String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) - throws SegueDatabaseException, MailjetException { - - Long userId = userRecord.getUserId(); - JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); - - if (mailjetDetails == null) { - log.warn("User ID {} has Mailjet ID {} but account not found. Treating as new user", userId, mailjetId); - database.updateExternalAccount(userId, null); - handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); - return; - } - - if (userRecord.isDeleted()) { - deleteUserFromMailJet(mailjetId, userRecord); - metrics.incrementDeleted(); - - } else if (accountEmailDeliveryFailed) { - log.info("User ID {} has delivery failed status. Unsubscribing from all lists", userId); - mailjetApi.updateUserSubscriptions(mailjetId, - MailJetSubscriptionAction.REMOVE, - MailJetSubscriptionAction.REMOVE); - metrics.incrementUnsubscribed(); - - } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { - log.info("User ID {} changed email. Recreating Mailjet account", userId); - mailjetApi.permanentlyDeleteAccountById(mailjetId); - String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + private static final Logger log = LoggerFactory.getLogger(ExternalAccountManager.class); + + private final IExternalAccountDataManager database; + private final MailJetApiClientWrapper mailjetApi; + + /** + * Synchronise account settings, email preferences and verification status with third party providers. + *
+ * Currently this class is highly specialised for synchronising with MailJet. + * + * @param mailjetApi - to enable updates on MailJet + * @param database - to persist external identifiers and to record sync success. + */ + public ExternalAccountManager(final MailJetApiClientWrapper mailjetApi, final IExternalAccountDataManager database) { + this.database = database; + this.mailjetApi = mailjetApi; + } + + /** + * Synchronise account settings and data with external providers. + *
+ * Whilst the actions this method takes are mostly idempotent, it should not be run simultaneously with itself. + * + * @throws ExternalAccountSynchronisationException on unrecoverable errors with external providers. + */ + @Override + public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchronisationException { + log.info("Starting Mailjet synchronization process"); + + List userRecordsToUpdate; + try { + userRecordsToUpdate = database.getRecentlyChangedRecords(); + log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); + } catch (SegueDatabaseException e) { + log.error("Database error whilst collecting users whose details have changed", e); + throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); + } + + if (userRecordsToUpdate.isEmpty()) { + log.info("No users to synchronize"); + return; + } + + SyncMetrics metrics = new SyncMetrics(); + + for (UserExternalAccountChanges userRecord : userRecordsToUpdate) { + Long userId = userRecord.getUserId(); + + try { + processUserSync(userRecord, metrics); + metrics.incrementSuccess(); + + } catch (SegueDatabaseException e) { + metrics.incrementDatabaseError(); + log.error("Database error storing Mailjet update for user ID: {}", userId, e); + // Continue processing other users + + } catch (MailjetClientCommunicationException e) { + metrics.incrementCommunicationError(); + log.error("Failed to communicate with Mailjet while processing user ID: {}", userId, e); + throw new ExternalAccountSynchronisationException("Failed to connect to Mailjet" + e); + + } catch (MailjetRateLimitException e) { + metrics.incrementRateLimitError(); + log.warn("Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit", + userId, metrics.getSuccessCount()); + throw new ExternalAccountSynchronisationException( + "Mailjet API rate limits exceeded after processing " + metrics.getSuccessCount() + " users" + e); + + } catch (MailjetException e) { + metrics.incrementMailjetError(); + log.error("Mailjet API error while processing user ID: {}. Continuing with next user", userId, e); + + } catch (Exception e) { + metrics.incrementUnexpectedError(); + log.error("Unexpected error processing user ID: {}", userId, e); + } + } + + logSyncSummary(metrics, userRecordsToUpdate.size()); + } + + /** + * Process synchronization for a single user. + */ + private void processUserSync(UserExternalAccountChanges userRecord, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + String accountEmail = userRecord.getAccountEmail(); + + if (accountEmail == null || accountEmail.trim().isEmpty()) { + log.warn("User ID {} has null or empty email address. Skipping", userId); + metrics.incrementSkipped(); + return; + } + + boolean accountEmailDeliveryFailed = + EmailVerificationStatus.DELIVERY_FAILED.equals(userRecord.getEmailVerificationStatus()); + String mailjetId = userRecord.getProviderUserId(); + + if (mailjetId != null && !mailjetId.trim().isEmpty()) { + handleExistingMailjetUser(mailjetId, userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + } else { + handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + } + + database.updateProviderLastUpdated(userId); + } + + /** + * Handle synchronization for users that already exist in Mailjet. + */ + private void handleExistingMailjetUser(String mailjetId, UserExternalAccountChanges userRecord, + String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + JSONObject mailjetDetails = mailjetApi.getAccountByIdOrEmail(mailjetId); + + if (mailjetDetails == null) { + log.warn("User ID {} has Mailjet ID {} but account not found. Treating as new user", userId, mailjetId); + database.updateExternalAccount(userId, null); + handleNewMailjetUser(userRecord, accountEmail, accountEmailDeliveryFailed, metrics); + return; + } + + if (userRecord.isDeleted()) { + deleteUserFromMailJet(mailjetId, userRecord); + metrics.incrementDeleted(); + + } else if (accountEmailDeliveryFailed) { + log.info("User ID {} has delivery failed status. Unsubscribing from all lists", userId); + mailjetApi.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.REMOVE, + MailJetSubscriptionAction.REMOVE); + metrics.incrementUnsubscribed(); + + } else if (!accountEmail.equalsIgnoreCase(mailjetDetails.getString("Email"))) { + log.info("User ID {} changed email. Recreating Mailjet account", userId); + mailjetApi.permanentlyDeleteAccountById(mailjetId); + String newMailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + + if (newMailjetId == null) { + throw new MailjetException("Failed to create new Mailjet account after email change for user: " + userId); + } + + updateUserOnMailJet(newMailjetId, userRecord); + metrics.incrementEmailChanged(); + + } else { + updateUserOnMailJet(mailjetId, userRecord); + metrics.incrementUpdated(); + } + } + + /** + * Handle synchronization for users that don't exist in Mailjet yet. + */ + private void handleNewMailjetUser(UserExternalAccountChanges userRecord, + String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) + throws SegueDatabaseException, MailjetException { + + Long userId = userRecord.getUserId(); + + if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { + log.info("Creating new Mailjet account for user ID {}", userId); + + String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); + + if (mailjetId == null) { + log.error("Failed to create Mailjet account for user ID {}. Mailjet returned null ID", userId); + throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); + } + + updateUserOnMailJet(mailjetId, userRecord); + metrics.incrementCreated(); + + } else { + log.debug("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping", + userId, userRecord.isDeleted(), accountEmailDeliveryFailed); + database.updateExternalAccount(userId, null); + metrics.incrementSkipped(); + } + } + + /** + * Update user details and subscriptions in Mailjet. + */ + private void updateUserOnMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) + throws SegueDatabaseException, MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); + } + + Long userId = userRecord.getUserId(); + String stage = userRecord.getStage() != null ? userRecord.getStage() : "unknown"; + + mailjetApi.updateUserProperties( + mailjetId, + userRecord.getGivenName(), + userRecord.getRole().toString(), + userRecord.getEmailVerificationStatus().toString(), + stage + ); + + MailJetSubscriptionAction newsStatus = Boolean.TRUE.equals(userRecord.allowsNewsEmails()) + ? MailJetSubscriptionAction.FORCE_SUBSCRIBE + : MailJetSubscriptionAction.UNSUBSCRIBE; + + MailJetSubscriptionAction eventsStatus = Boolean.TRUE.equals(userRecord.allowsEventsEmails()) + ? MailJetSubscriptionAction.FORCE_SUBSCRIBE + : MailJetSubscriptionAction.UNSUBSCRIBE; + + mailjetApi.updateUserSubscriptions(mailjetId, newsStatus, eventsStatus); + database.updateExternalAccount(userId, mailjetId); + + log.debug("Updated Mailjet account {} for user ID {} (news={}, events={})", + mailjetId, userId, newsStatus, eventsStatus); + } + + /** + * Delete user from Mailjet (GDPR compliance). + */ + private void deleteUserFromMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) + throws SegueDatabaseException, MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + log.warn("Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); + return; + } + + Long userId = userRecord.getUserId(); + mailjetApi.permanentlyDeleteAccountById(mailjetId); + database.updateExternalAccount(userId, null); + + log.info("Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); + } + + /** + * Log summary of synchronization results. + */ + private void logSyncSummary(SyncMetrics metrics, int totalUsers) { + log.info("=== Mailjet Synchronization Complete ==="); + log.info("Total users to process: {}", totalUsers); + log.info("Successfully processed: {}", metrics.getSuccessCount()); + log.info(" - Created: {}", metrics.getCreatedCount()); + log.info(" - Updated: {}", metrics.getUpdatedCount()); + log.info(" - Deleted: {}", metrics.getDeletedCount()); + log.info(" - Email changed: {}", metrics.getEmailChangedCount()); + log.info(" - Unsubscribed: {}", metrics.getUnsubscribedCount()); + log.info(" - Skipped: {}", metrics.getSkippedCount()); + log.info("Errors:"); + log.info(" - Database errors: {}", metrics.getDatabaseErrorCount()); + log.info(" - Communication errors: {}", metrics.getCommunicationErrorCount()); + log.info(" - Rate limit errors: {}", metrics.getRateLimitErrorCount()); + log.info(" - Mailjet API errors: {}", metrics.getMailjetErrorCount()); + log.info(" - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); + log.info("========================================"); + } + + /** + * Inner class to track synchronization metrics. + */ + private static class SyncMetrics { + private int successCount = 0; + private int createdCount = 0; + private int updatedCount = 0; + private int deletedCount = 0; + private int emailChangedCount = 0; + private int unsubscribedCount = 0; + private int skippedCount = 0; + private int databaseErrorCount = 0; + private int communicationErrorCount = 0; + private int rateLimitErrorCount = 0; + private int mailjetErrorCount = 0; + private int unexpectedErrorCount = 0; + + void incrementSuccess() { + successCount++; + } + + void incrementCreated() { + createdCount++; + } + + void incrementUpdated() { + updatedCount++; + } + + void incrementDeleted() { + deletedCount++; + } + + void incrementEmailChanged() { + emailChangedCount++; + } + + void incrementUnsubscribed() { + unsubscribedCount++; + } + + void incrementSkipped() { + skippedCount++; + } + + void incrementDatabaseError() { + databaseErrorCount++; + } + + void incrementCommunicationError() { + communicationErrorCount++; + } + + void incrementRateLimitError() { + rateLimitErrorCount++; + } + + void incrementMailjetError() { + mailjetErrorCount++; + } + + void incrementUnexpectedError() { + unexpectedErrorCount++; + } + + int getSuccessCount() { + return successCount; + } + + int getCreatedCount() { + return createdCount; + } + + int getUpdatedCount() { + return updatedCount; + } + + int getDeletedCount() { + return deletedCount; + } + + int getEmailChangedCount() { + return emailChangedCount; + } + + int getUnsubscribedCount() { + return unsubscribedCount; + } + + int getSkippedCount() { + return skippedCount; + } + + int getDatabaseErrorCount() { + return databaseErrorCount; + } + + int getCommunicationErrorCount() { + return communicationErrorCount; + } + + int getRateLimitErrorCount() { + return rateLimitErrorCount; + } + + int getMailjetErrorCount() { + return mailjetErrorCount; + } - if (newMailjetId == null) { - throw new MailjetException("Failed to create new Mailjet account after email change for user: " + userId); - } - - updateUserOnMailJet(newMailjetId, userRecord); - metrics.incrementEmailChanged(); - - } else { - updateUserOnMailJet(mailjetId, userRecord); - metrics.incrementUpdated(); - } - } - - /** - * Handle synchronization for users that don't exist in Mailjet yet. - */ - private void handleNewMailjetUser(UserExternalAccountChanges userRecord, - String accountEmail, boolean accountEmailDeliveryFailed, SyncMetrics metrics) - throws SegueDatabaseException, MailjetException { - - Long userId = userRecord.getUserId(); - - if (!accountEmailDeliveryFailed && !userRecord.isDeleted()) { - log.info("Creating new Mailjet account for user ID {}", userId); - - String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); - - if (mailjetId == null) { - log.error("Failed to create Mailjet account for user ID {}. Mailjet returned null ID", userId); - throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); - } - - updateUserOnMailJet(mailjetId, userRecord); - metrics.incrementCreated(); - - } else { - log.debug("User ID {} not eligible for Mailjet (deleted={}, deliveryFailed={}). Skipping", - userId, userRecord.isDeleted(), accountEmailDeliveryFailed); - database.updateExternalAccount(userId, null); - metrics.incrementSkipped(); - } - } - - /** - * Update user details and subscriptions in Mailjet. - */ - private void updateUserOnMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) - throws SegueDatabaseException, MailjetException { - - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); - } - - Long userId = userRecord.getUserId(); - String stage = userRecord.getStage() != null ? userRecord.getStage() : "unknown"; - - mailjetApi.updateUserProperties( - mailjetId, - userRecord.getGivenName(), - userRecord.getRole().toString(), - userRecord.getEmailVerificationStatus().toString(), - stage - ); - - MailJetSubscriptionAction newsStatus = Boolean.TRUE.equals(userRecord.allowsNewsEmails()) - ? MailJetSubscriptionAction.FORCE_SUBSCRIBE - : MailJetSubscriptionAction.UNSUBSCRIBE; - - MailJetSubscriptionAction eventsStatus = Boolean.TRUE.equals(userRecord.allowsEventsEmails()) - ? MailJetSubscriptionAction.FORCE_SUBSCRIBE - : MailJetSubscriptionAction.UNSUBSCRIBE; - - mailjetApi.updateUserSubscriptions(mailjetId, newsStatus, eventsStatus); - database.updateExternalAccount(userId, mailjetId); - - log.debug("Updated Mailjet account {} for user ID {} (news={}, events={})", - mailjetId, userId, newsStatus, eventsStatus); - } - - /** - * Delete user from Mailjet (GDPR compliance). - */ - private void deleteUserFromMailJet(final String mailjetId, final UserExternalAccountChanges userRecord) - throws SegueDatabaseException, MailjetException { - - if (mailjetId == null || mailjetId.trim().isEmpty()) { - log.warn("Attempted to delete user with null/empty Mailjet ID. User ID: {}", userRecord.getUserId()); - return; - } - - Long userId = userRecord.getUserId(); - mailjetApi.permanentlyDeleteAccountById(mailjetId); - database.updateExternalAccount(userId, null); - - log.info("Deleted Mailjet account {} for user ID {} (GDPR deletion)", mailjetId, userId); - } - - /** - * Log summary of synchronization results. - */ - private void logSyncSummary(SyncMetrics metrics, int totalUsers) { - log.info("=== Mailjet Synchronization Complete ==="); - log.info("Total users to process: {}", totalUsers); - log.info("Successfully processed: {}", metrics.getSuccessCount()); - log.info(" - Created: {}", metrics.getCreatedCount()); - log.info(" - Updated: {}", metrics.getUpdatedCount()); - log.info(" - Deleted: {}", metrics.getDeletedCount()); - log.info(" - Email changed: {}", metrics.getEmailChangedCount()); - log.info(" - Unsubscribed: {}", metrics.getUnsubscribedCount()); - log.info(" - Skipped: {}", metrics.getSkippedCount()); - log.info("Errors:"); - log.info(" - Database errors: {}", metrics.getDatabaseErrorCount()); - log.info(" - Communication errors: {}", metrics.getCommunicationErrorCount()); - log.info(" - Rate limit errors: {}", metrics.getRateLimitErrorCount()); - log.info(" - Mailjet API errors: {}", metrics.getMailjetErrorCount()); - log.info(" - Unexpected errors: {}", metrics.getUnexpectedErrorCount()); - log.info("========================================"); - } - - /** - * Inner class to track synchronization metrics. - */ - private static class SyncMetrics { - private int successCount = 0; - private int createdCount = 0; - private int updatedCount = 0; - private int deletedCount = 0; - private int emailChangedCount = 0; - private int unsubscribedCount = 0; - private int skippedCount = 0; - private int databaseErrorCount = 0; - private int communicationErrorCount = 0; - private int rateLimitErrorCount = 0; - private int mailjetErrorCount = 0; - private int unexpectedErrorCount = 0; - - void incrementSuccess() { - successCount++; - } - - void incrementCreated() { - createdCount++; - } - - void incrementUpdated() { - updatedCount++; - } - - void incrementDeleted() { - deletedCount++; - } - - void incrementEmailChanged() { - emailChangedCount++; - } - - void incrementUnsubscribed() { - unsubscribedCount++; - } - - void incrementSkipped() { - skippedCount++; - } - - void incrementDatabaseError() { - databaseErrorCount++; - } - - void incrementCommunicationError() { - communicationErrorCount++; - } - - void incrementRateLimitError() { - rateLimitErrorCount++; - } - - void incrementMailjetError() { - mailjetErrorCount++; - } - - void incrementUnexpectedError() { - unexpectedErrorCount++; - } - - int getSuccessCount() { - return successCount; - } - - int getCreatedCount() { - return createdCount; - } - - int getUpdatedCount() { - return updatedCount; - } - - int getDeletedCount() { - return deletedCount; - } - - int getEmailChangedCount() { - return emailChangedCount; - } - - int getUnsubscribedCount() { - return unsubscribedCount; - } - - int getSkippedCount() { - return skippedCount; - } - - int getDatabaseErrorCount() { - return databaseErrorCount; - } - - int getCommunicationErrorCount() { - return communicationErrorCount; - } - - int getRateLimitErrorCount() { - return rateLimitErrorCount; - } - - int getMailjetErrorCount() { - return mailjetErrorCount; - } - - int getUnexpectedErrorCount() { - return unexpectedErrorCount; - } + int getUnexpectedErrorCount() { + return unexpectedErrorCount; } + } } \ No newline at end of file diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java index 2a63d1a481..25fa9eae8b 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/configuration/SegueGuiceConfigurationModule.java @@ -51,7 +51,6 @@ import com.google.inject.name.Names; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; - import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -65,7 +64,6 @@ import java.util.Map; import java.util.Properties; import java.util.Set; - import org.apache.commons.lang3.SystemUtils; import org.elasticsearch.client.RestHighLevelClient; import org.slf4j.Logger; @@ -194,1123 +192,1126 @@ * This class is responsible for injecting configuration values for persistence related classes. */ public class SegueGuiceConfigurationModule extends AbstractModule implements ServletContextListener { - private static final Logger log = LoggerFactory.getLogger(SegueGuiceConfigurationModule.class); - - private static String version = null; - - private static Injector injector = null; - - private static PropertiesLoader globalProperties = null; - - // Singletons - we only ever want there to be one instance of each of these. - private static PostgresSqlDb postgresDB; - private static ContentMapperUtils mapperUtils = null; - private static GitContentManager contentManager = null; - private static RestHighLevelClient elasticSearchClient = null; - private static UserAccountManager userManager = null; - private static UserAuthenticationManager userAuthenticationManager = null; - private static IQuestionAttemptManager questionPersistenceManager = null; - private static SegueJobService segueJobService = null; - - private static LogManagerEventPublisher logManager; - private static EmailManager emailCommunicationQueue = null; - private static IMisuseMonitor misuseMonitor = null; - private static IMetricsExporter metricsExporter = null; - private static StatisticsManager statsManager = null; - private static GroupManager groupManager = null; - private static IExternalAccountManager externalAccountManager = null; - private static GameboardPersistenceManager gameboardPersistenceManager = null; - private static SchoolListReader schoolListReader = null; - private static AssignmentManager assignmentManager = null; - private static IGroupObserver groupObserver = null; - - private static Collection> contextListeners; - private static final Map>> classesByPackage = new HashMap<>(); - - /** - * A setter method that is mostly useful for testing. It populates the global properties static value if it has not - * previously been set. - * - * @param globalProperties PropertiesLoader object to be used for loading properties - * (if it has not previously been set). - */ - public static void setGlobalPropertiesIfNotSet(final PropertiesLoader globalProperties) { - if (SegueGuiceConfigurationModule.globalProperties == null) { - SegueGuiceConfigurationModule.globalProperties = globalProperties; - } - } - - /** - * Create a SegueGuiceConfigurationModule. - */ - public SegueGuiceConfigurationModule() { - if (globalProperties == null) { - // check the following places to determine where config file location may be. - // 1) system env variable, 2) java param (system property), 3) use a default from the constant file. - String configLocation = SystemUtils.IS_OS_LINUX ? DEFAULT_LINUX_CONFIG_LOCATION : null; - if (System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY) != null) { - configLocation = System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY); - } - if (System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY) != null) { - configLocation = System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY); - } - - try { - if (null == configLocation) { - throw new FileNotFoundException(SEGUE_CONFIG_LOCATION_NOT_SPECIFIED_MESSAGE); - } - - globalProperties = new PropertiesLoader(configLocation); - - log.info("Segue using configuration file: {}", configLocation); - - } catch (IOException e) { - log.error("Error loading properties file.", e); - } - } + private static final Logger log = LoggerFactory.getLogger(SegueGuiceConfigurationModule.class); + + private static String version = null; + + private static Injector injector = null; + + private static PropertiesLoader globalProperties = null; + + // Singletons - we only ever want there to be one instance of each of these. + private static PostgresSqlDb postgresDB; + private static ContentMapperUtils mapperUtils = null; + private static GitContentManager contentManager = null; + private static RestHighLevelClient elasticSearchClient = null; + private static UserAccountManager userManager = null; + private static UserAuthenticationManager userAuthenticationManager = null; + private static IQuestionAttemptManager questionPersistenceManager = null; + private static SegueJobService segueJobService = null; + + private static LogManagerEventPublisher logManager; + private static EmailManager emailCommunicationQueue = null; + private static IMisuseMonitor misuseMonitor = null; + private static IMetricsExporter metricsExporter = null; + private static StatisticsManager statsManager = null; + private static GroupManager groupManager = null; + private static IExternalAccountManager externalAccountManager = null; + private static GameboardPersistenceManager gameboardPersistenceManager = null; + private static SchoolListReader schoolListReader = null; + private static AssignmentManager assignmentManager = null; + private static IGroupObserver groupObserver = null; + + private static Collection> contextListeners; + private static final Map>> classesByPackage = new HashMap<>(); + + /** + * A setter method that is mostly useful for testing. It populates the global properties static value if it has not + * previously been set. + * + * @param globalProperties PropertiesLoader object to be used for loading properties + * (if it has not previously been set). + */ + public static void setGlobalPropertiesIfNotSet(final PropertiesLoader globalProperties) { + if (SegueGuiceConfigurationModule.globalProperties == null) { + SegueGuiceConfigurationModule.globalProperties = globalProperties; } - - @Override - protected void configure() { - try { - this.configureProperties(); - this.configureDataPersistence(); - this.configureSegueSearch(); - this.configureAuthenticationProviders(); - this.configureApplicationManagers(); - - } catch (IOException e) { - log.error("IOException during setup process.", e); + } + + /** + * Create a SegueGuiceConfigurationModule. + */ + public SegueGuiceConfigurationModule() { + if (globalProperties == null) { + // check the following places to determine where config file location may be. + // 1) system env variable, 2) java param (system property), 3) use a default from the constant file. + String configLocation = SystemUtils.IS_OS_LINUX ? DEFAULT_LINUX_CONFIG_LOCATION : null; + if (System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY) != null) { + configLocation = System.getProperty(CONFIG_LOCATION_SYSTEM_PROPERTY); + } + if (System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY) != null) { + configLocation = System.getenv(SEGUE_CONFIG_LOCATION_ENVIRONMENT_PROPERTY); + } + + try { + if (null == configLocation) { + throw new FileNotFoundException(SEGUE_CONFIG_LOCATION_NOT_SPECIFIED_MESSAGE); } - } - - /** - * Extract properties and bind them to constants. - */ - private void configureProperties() { - // Properties loader - bind(PropertiesLoader.class).toInstance(globalProperties); - - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_NAME, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_ADDRESS, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_INFO_PORT, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_USERNAME, globalProperties); - this.bindConstantToProperty(Constants.SEARCH_CLUSTER_PASSWORD, globalProperties); - - this.bindConstantToProperty(Constants.HOST_NAME, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_SERVER, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_USERNAME, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_PASSWORD, globalProperties); - this.bindConstantToProperty(Constants.MAILER_SMTP_PORT, globalProperties); - this.bindConstantToProperty(Constants.MAIL_FROM_ADDRESS, globalProperties); - this.bindConstantToProperty(Constants.MAIL_NAME, globalProperties); - - this.bindConstantToProperty(Constants.LOGGING_ENABLED, globalProperties); - - this.bindConstantToProperty(Constants.SCHOOL_CSV_LIST_PATH, globalProperties); - - this.bindConstantToProperty(CONTENT_INDEX, globalProperties); - - this.bindConstantToProperty(Constants.API_METRICS_EXPORT_PORT, globalProperties); - - this.bind(String.class).toProvider(() -> { - // Any binding to String without a matching @Named annotation will always get the empty string - // which seems incredibly likely to cause errors and rarely to be intended behaviour, - // so throw an error early in DEV and log an error in PROD. - try { - throw new IllegalArgumentException("Binding a String without a matching @Named annotation"); - } catch (IllegalArgumentException e) { - if (globalProperties.getProperty(SEGUE_APP_ENVIRONMENT).equals(DEV.name())) { - throw e; - } - log.error("Binding a String without a matching @Named annotation", e); - } - return ""; - }); - } - /** - * Configure all things persistence-related. - * - * @throws IOException when we cannot load the database. - */ - private void configureDataPersistence() throws IOException { - this.bindConstantToProperty(Constants.SEGUE_DB_NAME, globalProperties); - - // postgres - this.bindConstantToProperty(Constants.POSTGRES_DB_URL, globalProperties); - this.bindConstantToProperty(Constants.POSTGRES_DB_USER, globalProperties); - this.bindConstantToProperty(Constants.POSTGRES_DB_PASSWORD, globalProperties); - - // GitDb - bind(GitDb.class).toInstance( - new GitDb(globalProperties.getProperty(Constants.LOCAL_GIT_DB), globalProperties - .getProperty(Constants.REMOTE_GIT_SSH_URL), globalProperties - .getProperty(Constants.REMOTE_GIT_SSH_KEY_PATH))); - - bind(IUserGroupPersistenceManager.class).to(PgUserGroupPersistenceManager.class); - bind(IAssociationDataManager.class).to(PgAssociationDataManager.class); - bind(IAssignmentPersistenceManager.class).to(PgAssignmentPersistenceManager.class); - bind(IQuizAssignmentPersistenceManager.class).to(PgQuizAssignmentPersistenceManager.class); - bind(IQuizAttemptPersistenceManager.class).to(PgQuizAttemptPersistenceManager.class); - bind(IQuizQuestionAttemptPersistenceManager.class).to(PgQuizQuestionAttemptPersistenceManager.class); - bind(IUserBadgePersistenceManager.class).to(PgUserBadgePersistenceManager.class); - } + globalProperties = new PropertiesLoader(configLocation); - /** - * Configure segue search classes. - */ - private void configureSegueSearch() { - bind(ISearchProvider.class).to(ElasticSearchProvider.class); - } + log.info("Segue using configuration file: {}", configLocation); - /** - * Configure user security related classes. - */ - private void configureAuthenticationProviders() { - MapBinder mapBinder = MapBinder.newMapBinder(binder(), - AuthenticationProvider.class, IAuthenticator.class); - - this.bindConstantToProperty(Constants.HMAC_SALT, globalProperties); - //Google reCAPTCHA - this.bindConstantToProperty(Constants.GOOGLE_RECAPTCHA_SECRET, globalProperties); - - // Configure security providers - // Google - this.bindConstantToProperty(Constants.GOOGLE_CLIENT_SECRET_LOCATION, globalProperties); - this.bindConstantToProperty(Constants.GOOGLE_CALLBACK_URI, globalProperties); - this.bindConstantToProperty(Constants.GOOGLE_OAUTH_SCOPES, globalProperties); - mapBinder.addBinding(AuthenticationProvider.GOOGLE).to(GoogleAuthenticator.class); - - // Facebook - this.bindConstantToProperty(Constants.FACEBOOK_SECRET, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_CLIENT_ID, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_CALLBACK_URI, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_OAUTH_SCOPES, globalProperties); - this.bindConstantToProperty(Constants.FACEBOOK_USER_FIELDS, globalProperties); - mapBinder.addBinding(AuthenticationProvider.FACEBOOK).to(FacebookAuthenticator.class); - - // Twitter - this.bindConstantToProperty(Constants.TWITTER_SECRET, globalProperties); - this.bindConstantToProperty(Constants.TWITTER_CLIENT_ID, globalProperties); - this.bindConstantToProperty(Constants.TWITTER_CALLBACK_URI, globalProperties); - mapBinder.addBinding(AuthenticationProvider.TWITTER).to(TwitterAuthenticator.class); - - // Segue local - mapBinder.addBinding(AuthenticationProvider.SEGUE).to(SegueLocalAuthenticator.class); + } catch (IOException e) { + log.error("Error loading properties file.", e); + } } - - /** - * Deals with application data managers. - */ - private void configureApplicationManagers() { - bind(IUserDataManager.class).to(PgUsers.class); - - bind(IAnonymousUserDataManager.class).to(PgAnonymousUsers.class); - - bind(IPasswordDataManager.class).to(PgPasswordDataManager.class); - - bind(ICommunicator.class).to(EmailCommunicator.class); - - bind(AbstractUserPreferenceManager.class).to(PgUserPreferenceManager.class); - - bind(IUserAlerts.class).to(PgUserAlerts.class); - - bind(IUserStreaksManager.class).to(PgUserStreakManager.class); - - bind(IStatisticsManager.class).to(StatisticsManager.class); - - bind(ITransactionManager.class).to(PgTransactionManager.class); - - bind(ITOTPDataManager.class).to(PgTOTPDataManager.class); - - bind(ISecondFactorAuthenticator.class).to(SegueTOTPAuthenticator.class); + } + + @Override + protected void configure() { + try { + this.configureProperties(); + this.configureDataPersistence(); + this.configureSegueSearch(); + this.configureAuthenticationProviders(); + this.configureApplicationManagers(); + + } catch (IOException e) { + log.error("IOException during setup process.", e); } - - - @Inject - @Provides - @Singleton - private static IMetricsExporter getMetricsExporter( - @Named(Constants.API_METRICS_EXPORT_PORT) final int port) { - if (null == metricsExporter) { - try { - log.info("Creating MetricsExporter on port ({})", port); - metricsExporter = new PrometheusMetricsExporter(port); - log.info("Exporting default JVM metrics."); - metricsExporter.exposeJvmMetrics(); - } catch (IOException e) { - log.error("Could not create MetricsExporter on port ({})", port); - return null; - } + } + + /** + * Extract properties and bind them to constants. + */ + private void configureProperties() { + // Properties loader + bind(PropertiesLoader.class).toInstance(globalProperties); + + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_NAME, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_ADDRESS, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_INFO_PORT, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_USERNAME, globalProperties); + this.bindConstantToProperty(Constants.SEARCH_CLUSTER_PASSWORD, globalProperties); + + this.bindConstantToProperty(Constants.HOST_NAME, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_SERVER, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_USERNAME, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_PASSWORD, globalProperties); + this.bindConstantToProperty(Constants.MAILER_SMTP_PORT, globalProperties); + this.bindConstantToProperty(Constants.MAIL_FROM_ADDRESS, globalProperties); + this.bindConstantToProperty(Constants.MAIL_NAME, globalProperties); + + this.bindConstantToProperty(Constants.LOGGING_ENABLED, globalProperties); + + this.bindConstantToProperty(Constants.SCHOOL_CSV_LIST_PATH, globalProperties); + + this.bindConstantToProperty(CONTENT_INDEX, globalProperties); + + this.bindConstantToProperty(Constants.API_METRICS_EXPORT_PORT, globalProperties); + + this.bind(String.class).toProvider(() -> { + // Any binding to String without a matching @Named annotation will always get the empty string + // which seems incredibly likely to cause errors and rarely to be intended behaviour, + // so throw an error early in DEV and log an error in PROD. + try { + throw new IllegalArgumentException("Binding a String without a matching @Named annotation"); + } catch (IllegalArgumentException e) { + if (globalProperties.getProperty(SEGUE_APP_ENVIRONMENT).equals(DEV.name())) { + throw e; } - return metricsExporter; + log.error("Binding a String without a matching @Named annotation", e); + } + return ""; + }); + } + + /** + * Configure all things persistence-related. + * + * @throws IOException when we cannot load the database. + */ + private void configureDataPersistence() throws IOException { + this.bindConstantToProperty(Constants.SEGUE_DB_NAME, globalProperties); + + // postgres + this.bindConstantToProperty(Constants.POSTGRES_DB_URL, globalProperties); + this.bindConstantToProperty(Constants.POSTGRES_DB_USER, globalProperties); + this.bindConstantToProperty(Constants.POSTGRES_DB_PASSWORD, globalProperties); + + // GitDb + bind(GitDb.class).toInstance( + new GitDb(globalProperties.getProperty(Constants.LOCAL_GIT_DB), globalProperties + .getProperty(Constants.REMOTE_GIT_SSH_URL), globalProperties + .getProperty(Constants.REMOTE_GIT_SSH_KEY_PATH))); + + bind(IUserGroupPersistenceManager.class).to(PgUserGroupPersistenceManager.class); + bind(IAssociationDataManager.class).to(PgAssociationDataManager.class); + bind(IAssignmentPersistenceManager.class).to(PgAssignmentPersistenceManager.class); + bind(IQuizAssignmentPersistenceManager.class).to(PgQuizAssignmentPersistenceManager.class); + bind(IQuizAttemptPersistenceManager.class).to(PgQuizAttemptPersistenceManager.class); + bind(IQuizQuestionAttemptPersistenceManager.class).to(PgQuizQuestionAttemptPersistenceManager.class); + bind(IUserBadgePersistenceManager.class).to(PgUserBadgePersistenceManager.class); + } + + /** + * Configure segue search classes. + */ + private void configureSegueSearch() { + bind(ISearchProvider.class).to(ElasticSearchProvider.class); + } + + /** + * Configure user security related classes. + */ + private void configureAuthenticationProviders() { + + this.bindConstantToProperty(Constants.HMAC_SALT, globalProperties); + //Google reCAPTCHA + this.bindConstantToProperty(Constants.GOOGLE_RECAPTCHA_SECRET, globalProperties); + + // Configure security providers + // Google + this.bindConstantToProperty(Constants.GOOGLE_CLIENT_SECRET_LOCATION, globalProperties); + this.bindConstantToProperty(Constants.GOOGLE_CALLBACK_URI, globalProperties); + this.bindConstantToProperty(Constants.GOOGLE_OAUTH_SCOPES, globalProperties); + + MapBinder mapBinder = MapBinder.newMapBinder(binder(), + AuthenticationProvider.class, IAuthenticator.class); + + + mapBinder.addBinding(AuthenticationProvider.GOOGLE).to(GoogleAuthenticator.class); + + // Facebook + this.bindConstantToProperty(Constants.FACEBOOK_SECRET, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_CLIENT_ID, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_CALLBACK_URI, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_OAUTH_SCOPES, globalProperties); + this.bindConstantToProperty(Constants.FACEBOOK_USER_FIELDS, globalProperties); + mapBinder.addBinding(AuthenticationProvider.FACEBOOK).to(FacebookAuthenticator.class); + + // Twitter + this.bindConstantToProperty(Constants.TWITTER_SECRET, globalProperties); + this.bindConstantToProperty(Constants.TWITTER_CLIENT_ID, globalProperties); + this.bindConstantToProperty(Constants.TWITTER_CALLBACK_URI, globalProperties); + mapBinder.addBinding(AuthenticationProvider.TWITTER).to(TwitterAuthenticator.class); + + // Segue local + mapBinder.addBinding(AuthenticationProvider.SEGUE).to(SegueLocalAuthenticator.class); + } + + /** + * Deals with application data managers. + */ + private void configureApplicationManagers() { + bind(IUserDataManager.class).to(PgUsers.class); + + bind(IAnonymousUserDataManager.class).to(PgAnonymousUsers.class); + + bind(IPasswordDataManager.class).to(PgPasswordDataManager.class); + + bind(ICommunicator.class).to(EmailCommunicator.class); + + bind(AbstractUserPreferenceManager.class).to(PgUserPreferenceManager.class); + + bind(IUserAlerts.class).to(PgUserAlerts.class); + + bind(IUserStreaksManager.class).to(PgUserStreakManager.class); + + bind(IStatisticsManager.class).to(StatisticsManager.class); + + bind(ITransactionManager.class).to(PgTransactionManager.class); + + bind(ITOTPDataManager.class).to(PgTOTPDataManager.class); + + bind(ISecondFactorAuthenticator.class).to(SegueTOTPAuthenticator.class); + } + + + @Inject + @Provides + @Singleton + private static IMetricsExporter getMetricsExporter( + @Named(Constants.API_METRICS_EXPORT_PORT) final int port) { + if (null == metricsExporter) { + try { + log.info("Creating MetricsExporter on port ({})", port); + metricsExporter = new PrometheusMetricsExporter(port); + log.info("Exporting default JVM metrics."); + metricsExporter.exposeJvmMetrics(); + } catch (IOException e) { + log.error("Could not create MetricsExporter on port ({})", port); + return null; + } } - - /** - * This provides a singleton of the elasticSearch client that can be used by Guice. - *
- * The client is threadsafe, so we don't need to keep creating new ones. - * - * @param address address of the cluster to create. - * @param port port of the cluster to create. - * @param username username for cluster user. - * @param password password for cluster user. - * @return Client to be injected into ElasticSearch Provider. - */ - @Inject - @Provides - @Singleton - private static RestHighLevelClient getSearchConnectionInformation( - @Named(Constants.SEARCH_CLUSTER_ADDRESS) final String address, - @Named(Constants.SEARCH_CLUSTER_INFO_PORT) final int port, - @Named(Constants.SEARCH_CLUSTER_USERNAME) final String username, - @Named(Constants.SEARCH_CLUSTER_PASSWORD) final String password) { - if (null == elasticSearchClient) { - try { - elasticSearchClient = ElasticSearchProvider.getClient(address, port, username, password); - log.info("Creating singleton of ElasticSearchProvider"); - } catch (UnknownHostException e) { - log.error("Could not create ElasticSearchProvider"); - return null; - } - } - // eventually we want to do something like the below to make sure we get updated clients - // if (elasticSearchClient instanceof TransportClient) { - // TransportClient tc = (TransportClient) elasticSearchClient; - // if (tc.connectedNodes().isEmpty()) { - // tc.close(); - // log.error("The elasticsearch client is not connected to any nodes. Trying to reconnect..."); - // elasticSearchClient = null; - // return getSearchConnectionInformation(clusterName, address, port); - // } - // } - - return elasticSearchClient; + return metricsExporter; + } + + /** + * This provides a singleton of the elasticSearch client that can be used by Guice. + *
+ * The client is threadsafe, so we don't need to keep creating new ones. + * + * @param address address of the cluster to create. + * @param port port of the cluster to create. + * @param username username for cluster user. + * @param password password for cluster user. + * @return Client to be injected into ElasticSearch Provider. + */ + @Inject + @Provides + @Singleton + private static RestHighLevelClient getSearchConnectionInformation( + @Named(Constants.SEARCH_CLUSTER_ADDRESS) final String address, + @Named(Constants.SEARCH_CLUSTER_INFO_PORT) final int port, + @Named(Constants.SEARCH_CLUSTER_USERNAME) final String username, + @Named(Constants.SEARCH_CLUSTER_PASSWORD) final String password) { + if (null == elasticSearchClient) { + try { + elasticSearchClient = ElasticSearchProvider.getClient(address, port, username, password); + log.info("Creating singleton of ElasticSearchProvider"); + } catch (UnknownHostException e) { + log.error("Could not create ElasticSearchProvider"); + return null; + } } - - /** - * This provides a singleton of the git content manager for the segue facade. - *
- * TODO: This is a singleton as the units and tags are stored in memory. If we move these out it can be an instance. - * This would be better as then we can give it a new search provider if the client has closed. - * - * @param database database reference - * @param searchProvider search provider to use - * @param contentMapperUtils content mapper to use. - * @param globalProperties properties loader to use - * @return a fully configured content Manager. - */ - @Inject - @Provides - @Singleton - private static GitContentManager getContentManager(final GitDb database, final ISearchProvider searchProvider, - final ContentMapperUtils contentMapperUtils, - final ContentMapper objectMapper, - final PropertiesLoader globalProperties) { - if (null == contentManager) { - contentManager = - new GitContentManager(database, searchProvider, contentMapperUtils, objectMapper, globalProperties); - log.info("Creating singleton of ContentManager"); - } - - return contentManager; + // eventually we want to do something like the below to make sure we get updated clients + // if (elasticSearchClient instanceof TransportClient) { + // TransportClient tc = (TransportClient) elasticSearchClient; + // if (tc.connectedNodes().isEmpty()) { + // tc.close(); + // log.error("The elasticsearch client is not connected to any nodes. Trying to reconnect..."); + // elasticSearchClient = null; + // return getSearchConnectionInformation(clusterName, address, port); + // } + // } + + return elasticSearchClient; + } + + /** + * This provides a singleton of the git content manager for the segue facade. + *
+ * TODO: This is a singleton as the units and tags are stored in memory. If we move these out it can be an instance. + * This would be better as then we can give it a new search provider if the client has closed. + * + * @param database database reference + * @param searchProvider search provider to use + * @param contentMapperUtils content mapper to use. + * @param globalProperties properties loader to use + * @return a fully configured content Manager. + */ + @Inject + @Provides + @Singleton + private static GitContentManager getContentManager(final GitDb database, final ISearchProvider searchProvider, + final ContentMapperUtils contentMapperUtils, + final ContentMapper objectMapper, + final PropertiesLoader globalProperties) { + if (null == contentManager) { + contentManager = + new GitContentManager(database, searchProvider, contentMapperUtils, objectMapper, globalProperties); + log.info("Creating singleton of ContentManager"); } - /** - * This provides a singleton of the LogManager for the Segue facade. - *
- * Note: This is a singleton as logs are created very often and we wanted to minimise the overhead in class - * creation. Although we can convert this to instances if we want to tidy this up. - * - * @param database database reference - * @param loggingEnabled boolean to determine if we should persist log messages. - * @return A fully configured LogManager - */ - @Inject - @Provides - @Singleton - private static ILogManager getLogManager(final PostgresSqlDb database, - @Named(Constants.LOGGING_ENABLED) final boolean loggingEnabled) { - - if (null == logManager) { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); - objectMapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); - logManager = new PgLogManagerEventListener(new PgLogManager(database, objectMapper, loggingEnabled)); - - log.info("Creating singleton of LogManager"); - if (loggingEnabled) { - log.info("Log manager configured to record logging."); - } else { - log.info("Log manager configured NOT to record logging."); - } - } - - return logManager; + return contentManager; + } + + /** + * This provides a singleton of the LogManager for the Segue facade. + *
+ * Note: This is a singleton as logs are created very often and we wanted to minimise the overhead in class + * creation. Although we can convert this to instances if we want to tidy this up. + * + * @param database database reference + * @param loggingEnabled boolean to determine if we should persist log messages. + * @return A fully configured LogManager + */ + @Inject + @Provides + @Singleton + private static ILogManager getLogManager(final PostgresSqlDb database, + @Named(Constants.LOGGING_ENABLED) final boolean loggingEnabled) { + + if (null == logManager) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + objectMapper.configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false); + logManager = new PgLogManagerEventListener(new PgLogManager(database, objectMapper, loggingEnabled)); + + log.info("Creating singleton of LogManager"); + if (loggingEnabled) { + log.info("Log manager configured to record logging."); + } else { + log.info("Log manager configured NOT to record logging."); + } } - /** - * This provides a singleton of the contentVersionController for the segue facade. - * Note: This is a singleton because this content mapper has to use reflection to register all content classes. - * - * @return Content version controller with associated dependencies. - */ - @Inject - @Provides - @Singleton - private static ContentMapperUtils getContentMapper() { - if (null == mapperUtils) { - Set> c = getClasses("uk.ac.cam.cl.dtg"); - mapperUtils = new ContentMapperUtils(c); - log.info("Creating Singleton of the Content Mapper"); - } - - return mapperUtils; + return logManager; + } + + /** + * This provides a singleton of the contentVersionController for the segue facade. + * Note: This is a singleton because this content mapper has to use reflection to register all content classes. + * + * @return Content version controller with associated dependencies. + */ + @Inject + @Provides + @Singleton + private static ContentMapperUtils getContentMapper() { + if (null == mapperUtils) { + Set> c = getClasses("uk.ac.cam.cl.dtg"); + mapperUtils = new ContentMapperUtils(c); + log.info("Creating Singleton of the Content Mapper"); } - /** - * This provides an instance of the SegueLocalAuthenticator. - *
- * - * @param database the database to access userInformation - * @param passwordDataManager the database to access passwords - * @param properties the global system properties - * @return an instance of the queue - */ - @Inject - @Provides - private static SegueLocalAuthenticator getSegueLocalAuthenticator(final IUserDataManager database, - final IPasswordDataManager passwordDataManager, - final PropertiesLoader properties) { - ISegueHashingAlgorithm preferredAlgorithm = new SegueSCryptv1(); - ISegueHashingAlgorithm oldAlgorithm1 = new SeguePBKDF2v1(); - ISegueHashingAlgorithm oldAlgorithm2 = new SeguePBKDF2v2(); - ISegueHashingAlgorithm oldAlgorithm3 = new SeguePBKDF2v3(); - - Map possibleAlgorithms = Map.of( - preferredAlgorithm.hashingAlgorithmName(), preferredAlgorithm, - oldAlgorithm1.hashingAlgorithmName(), oldAlgorithm1, - oldAlgorithm2.hashingAlgorithmName(), oldAlgorithm2, - oldAlgorithm3.hashingAlgorithmName(), oldAlgorithm3 - ); - - return new SegueLocalAuthenticator(database, passwordDataManager, properties, possibleAlgorithms, - preferredAlgorithm); + return mapperUtils; + } + + /** + * This provides an instance of the SegueLocalAuthenticator. + *
+ * + * @param database the database to access userInformation + * @param passwordDataManager the database to access passwords + * @param properties the global system properties + * @return an instance of the queue + */ + @Inject + @Provides + private static SegueLocalAuthenticator getSegueLocalAuthenticator(final IUserDataManager database, + final IPasswordDataManager passwordDataManager, + final PropertiesLoader properties) { + ISegueHashingAlgorithm preferredAlgorithm = new SegueSCryptv1(); + ISegueHashingAlgorithm oldAlgorithm1 = new SeguePBKDF2v1(); + ISegueHashingAlgorithm oldAlgorithm2 = new SeguePBKDF2v2(); + ISegueHashingAlgorithm oldAlgorithm3 = new SeguePBKDF2v3(); + + Map possibleAlgorithms = Map.of( + preferredAlgorithm.hashingAlgorithmName(), preferredAlgorithm, + oldAlgorithm1.hashingAlgorithmName(), oldAlgorithm1, + oldAlgorithm2.hashingAlgorithmName(), oldAlgorithm2, + oldAlgorithm3.hashingAlgorithmName(), oldAlgorithm3 + ); + + return new SegueLocalAuthenticator(database, passwordDataManager, properties, possibleAlgorithms, + preferredAlgorithm); + } + + /** + * This provides a singleton of the e-mail manager class. + *
+ * Note: This has to be a singleton because it manages all emails sent using this JVM. + * + * @param properties the properties so we can generate email + * @param emailCommunicator the class the queue will send messages with + * @param userPreferenceManager the class providing email preferences + * @param contentManager the content so we can access email templates + * @param logManager the logManager to log email sent + * @return an instance of the queue + */ + @Inject + @Provides + @Singleton + private static EmailManager getMessageCommunicationQueue(final PropertiesLoader properties, + final EmailCommunicator emailCommunicator, + final AbstractUserPreferenceManager userPreferenceManager, + final GitContentManager contentManager, + final ILogManager logManager) { + + Map globalTokens = Maps.newHashMap(); + globalTokens.put("sig", properties.getProperty(EMAIL_SIGNATURE)); + globalTokens.put("emailPreferencesURL", String.format("https://%s/account#emailpreferences", + properties.getProperty(HOST_NAME))); + globalTokens.put("myAssignmentsURL", String.format("https://%s/assignments", + properties.getProperty(HOST_NAME))); + String myQuizzesURL = String.format("https://%s/tests", properties.getProperty(HOST_NAME)); + globalTokens.put("myQuizzesURL", myQuizzesURL); + globalTokens.put("myTestsURL", myQuizzesURL); + globalTokens.put("myBookedEventsURL", String.format("https://%s/events?show_booked_only=true", + properties.getProperty(HOST_NAME))); + globalTokens.put("contactUsURL", String.format("https://%s/contact", + properties.getProperty(HOST_NAME))); + globalTokens.put("accountURL", String.format("https://%s/account", + properties.getProperty(HOST_NAME))); + globalTokens.put("siteBaseURL", String.format("https://%s", properties.getProperty(HOST_NAME))); + + if (null == emailCommunicationQueue) { + emailCommunicationQueue = new EmailManager(emailCommunicator, userPreferenceManager, properties, + contentManager, logManager, globalTokens); + log.info("Creating singleton of EmailCommunicationQueue"); } - - /** - * This provides a singleton of the e-mail manager class. - *
- * Note: This has to be a singleton because it manages all emails sent using this JVM. - * - * @param properties the properties so we can generate email - * @param emailCommunicator the class the queue will send messages with - * @param userPreferenceManager the class providing email preferences - * @param contentManager the content so we can access email templates - * @param logManager the logManager to log email sent - * @return an instance of the queue - */ - @Inject - @Provides - @Singleton - private static EmailManager getMessageCommunicationQueue(final PropertiesLoader properties, - final EmailCommunicator emailCommunicator, - final AbstractUserPreferenceManager userPreferenceManager, - final GitContentManager contentManager, - final ILogManager logManager) { - - Map globalTokens = Maps.newHashMap(); - globalTokens.put("sig", properties.getProperty(EMAIL_SIGNATURE)); - globalTokens.put("emailPreferencesURL", String.format("https://%s/account#emailpreferences", - properties.getProperty(HOST_NAME))); - globalTokens.put("myAssignmentsURL", String.format("https://%s/assignments", - properties.getProperty(HOST_NAME))); - String myQuizzesURL = String.format("https://%s/tests", properties.getProperty(HOST_NAME)); - globalTokens.put("myQuizzesURL", myQuizzesURL); - globalTokens.put("myTestsURL", myQuizzesURL); - globalTokens.put("myBookedEventsURL", String.format("https://%s/events?show_booked_only=true", - properties.getProperty(HOST_NAME))); - globalTokens.put("contactUsURL", String.format("https://%s/contact", - properties.getProperty(HOST_NAME))); - globalTokens.put("accountURL", String.format("https://%s/account", - properties.getProperty(HOST_NAME))); - globalTokens.put("siteBaseURL", String.format("https://%s", properties.getProperty(HOST_NAME))); - - if (null == emailCommunicationQueue) { - emailCommunicationQueue = new EmailManager(emailCommunicator, userPreferenceManager, properties, - contentManager, logManager, globalTokens); - log.info("Creating singleton of EmailCommunicationQueue"); - } - return emailCommunicationQueue; + return emailCommunicationQueue; + } + + /** + * This provides a singleton of the UserManager for various facades. + *
+ * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. + * + * @param database the user persistence manager. + * @param properties properties loader + * @param providersToRegister list of known providers. + * @param emailQueue so that we can send e-mails. + * @return Content version controller with associated dependencies. + */ + @Inject + @Provides + @Singleton + private UserAuthenticationManager getUserAuthenticationManager( + final IUserDataManager database, final PropertiesLoader properties, + final Map providersToRegister, final EmailManager emailQueue) { + if (null == userAuthenticationManager) { + userAuthenticationManager = new UserAuthenticationManager(database, properties, providersToRegister, emailQueue); + log.info("Creating singleton of UserAuthenticationManager"); } - /** - * This provides a singleton of the UserManager for various facades. - *
- * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. - * - * @param database the user persistence manager. - * @param properties properties loader - * @param providersToRegister list of known providers. - * @param emailQueue so that we can send e-mails. - * @return Content version controller with associated dependencies. - */ - @Inject - @Provides - @Singleton - private UserAuthenticationManager getUserAuthenticationManager( - final IUserDataManager database, final PropertiesLoader properties, - final Map providersToRegister, final EmailManager emailQueue) { - if (null == userAuthenticationManager) { - userAuthenticationManager = new UserAuthenticationManager(database, properties, providersToRegister, emailQueue); - log.info("Creating singleton of UserAuthenticationManager"); - } - - return userAuthenticationManager; + return userAuthenticationManager; + } + + /** + * This provides a singleton of the UserManager for various facades. + *
+ * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. + * + * @param database the user persistence manager. + * @param questionManager IUserManager + * @param properties properties loader + * @param providersToRegister list of known providers. + * @param emailQueue so that we can send e-mails. + * @param temporaryUserCache to manage temporary anonymous users + * @param logManager so that we can log interesting user based events. + * @param mapperFacade for DO and DTO mapping. + * @param userAuthenticationManager Responsible for handling the various authentication functions. + * @param secondFactorManager For managing TOTP multifactor authentication. + * @param userPreferenceManager For managing user preferences. + * @return Content version controller with associated dependencies. + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @Inject + @Provides + @Singleton + private IUserAccountManager getUserManager(final IUserDataManager database, final QuestionManager questionManager, + final PropertiesLoader properties, + final Map providersToRegister, + final EmailManager emailQueue, + final IAnonymousUserDataManager temporaryUserCache, + final ILogManager logManager, final MainObjectMapper mapperFacade, + final UserAuthenticationManager userAuthenticationManager, + final ISecondFactorAuthenticator secondFactorManager, + final AbstractUserPreferenceManager userPreferenceManager, + final SchoolListReader schoolListReader) { + if (null == userManager) { + userManager = new UserAccountManager(database, questionManager, properties, providersToRegister, + mapperFacade, emailQueue, temporaryUserCache, logManager, userAuthenticationManager, + secondFactorManager, userPreferenceManager, schoolListReader); + log.info("Creating singleton of UserManager"); } - /** - * This provides a singleton of the UserManager for various facades. - *
- * Note: This has to be a singleton as the User Manager keeps a temporary cache of anonymous users. - * - * @param database the user persistence manager. - * @param questionManager IUserManager - * @param properties properties loader - * @param providersToRegister list of known providers. - * @param emailQueue so that we can send e-mails. - * @param temporaryUserCache to manage temporary anonymous users - * @param logManager so that we can log interesting user based events. - * @param mapperFacade for DO and DTO mapping. - * @param userAuthenticationManager Responsible for handling the various authentication functions. - * @param secondFactorManager For managing TOTP multifactor authentication. - * @param userPreferenceManager For managing user preferences. - * @return Content version controller with associated dependencies. - */ - @SuppressWarnings("checkstyle:ParameterNumber") - @Inject - @Provides - @Singleton - private IUserAccountManager getUserManager(final IUserDataManager database, final QuestionManager questionManager, - final PropertiesLoader properties, - final Map providersToRegister, - final EmailManager emailQueue, - final IAnonymousUserDataManager temporaryUserCache, - final ILogManager logManager, final MainObjectMapper mapperFacade, - final UserAuthenticationManager userAuthenticationManager, - final ISecondFactorAuthenticator secondFactorManager, - final AbstractUserPreferenceManager userPreferenceManager, - final SchoolListReader schoolListReader) { - if (null == userManager) { - userManager = new UserAccountManager(database, questionManager, properties, providersToRegister, - mapperFacade, emailQueue, temporaryUserCache, logManager, userAuthenticationManager, - secondFactorManager, userPreferenceManager, schoolListReader); - log.info("Creating singleton of UserManager"); - } - - return userManager; + return userManager; + } + + /** + * QuestionManager. + * Note: This has to be a singleton as the question manager keeps anonymous question attempts in memory. + * + * @param ds postgres data source + * @param objectMapper mapper + * @return a singleton for question persistence. + */ + @Inject + @Provides + @Singleton + private IQuestionAttemptManager getQuestionManager(final PostgresSqlDb ds, final ContentMapperUtils objectMapper) { + // this needs to be a singleton as it provides a temporary cache for anonymous question attempts. + if (null == questionPersistenceManager) { + questionPersistenceManager = new PgQuestionAttempts(ds, objectMapper); + log.info("Creating singleton of IQuestionAttemptManager"); } - /** - * QuestionManager. - * Note: This has to be a singleton as the question manager keeps anonymous question attempts in memory. - * - * @param ds postgres data source - * @param objectMapper mapper - * @return a singleton for question persistence. - */ - @Inject - @Provides - @Singleton - private IQuestionAttemptManager getQuestionManager(final PostgresSqlDb ds, final ContentMapperUtils objectMapper) { - // this needs to be a singleton as it provides a temporary cache for anonymous question attempts. - if (null == questionPersistenceManager) { - questionPersistenceManager = new PgQuestionAttempts(ds, objectMapper); - log.info("Creating singleton of IQuestionAttemptManager"); - } - - return questionPersistenceManager; + return questionPersistenceManager; + } + + /** + * This provides a singleton of the GroupManager. + *
+ * Note: This needs to be a singleton as we register observers for groups. + * + * @param userGroupDataManager user group data manager + * @param userManager user manager + * @param gameManager game manager + * @param dtoMapper dtoMapper + * @return group manager + */ + @Inject + @Provides + @Singleton + private GroupManager getGroupManager(final IUserGroupPersistenceManager userGroupDataManager, + final UserAccountManager userManager, final GameManager gameManager, + final UserMapper dtoMapper) { + + if (null == groupManager) { + groupManager = new GroupManager(userGroupDataManager, userManager, gameManager, dtoMapper); + log.info("Creating singleton of GroupManager"); } - /** - * This provides a singleton of the GroupManager. - *
- * Note: This needs to be a singleton as we register observers for groups. - * - * @param userGroupDataManager user group data manager - * @param userManager user manager - * @param gameManager game manager - * @param dtoMapper dtoMapper - * @return group manager - */ - @Inject - @Provides - @Singleton - private GroupManager getGroupManager(final IUserGroupPersistenceManager userGroupDataManager, - final UserAccountManager userManager, final GameManager gameManager, - final UserMapper dtoMapper) { - - if (null == groupManager) { - groupManager = new GroupManager(userGroupDataManager, userManager, gameManager, dtoMapper); - log.info("Creating singleton of GroupManager"); - } - - return groupManager; - } + return groupManager; + } - @Inject - @Provides - @Singleton - private IGroupObserver getGroupObserver( - final EmailManager emailManager, final GroupManager groupManager, final UserAccountManager userManager, - final AssignmentManager assignmentManager, final QuizAssignmentManager quizAssignmentManager) { - if (null == groupObserver) { - groupObserver = - new GroupChangedService(emailManager, groupManager, userManager, assignmentManager, quizAssignmentManager); - log.info("Creating singleton of GroupObserver"); - } - return groupObserver; + @Inject + @Provides + @Singleton + private IGroupObserver getGroupObserver( + final EmailManager emailManager, final GroupManager groupManager, final UserAccountManager userManager, + final AssignmentManager assignmentManager, final QuizAssignmentManager quizAssignmentManager) { + if (null == groupObserver) { + groupObserver = + new GroupChangedService(emailManager, groupManager, userManager, assignmentManager, quizAssignmentManager); + log.info("Creating singleton of GroupObserver"); } + return groupObserver; + } - /** - * Get singleton of misuseMonitor. - *
- * Note: this has to be a singleton as it tracks (in memory) the number of misuses. - * - * @param emailManager so that the monitors can send e-mails. - * @param properties so that the monitors can look up email settings etc. - * @return gets the singleton of the misuse manager. - */ - @Inject - @Provides - @Singleton - private IMisuseMonitor getMisuseMonitor(final EmailManager emailManager, final PropertiesLoader properties) { - if (null == misuseMonitor) { - misuseMonitor = new InMemoryMisuseMonitor(); - log.info("Creating singleton of MisuseMonitor"); - - // TODO: We should automatically register all handlers that implement this interface using reflection? - // register handlers segue specific handlers - misuseMonitor.registerHandler(TokenOwnerLookupMisuseHandler.class.getSimpleName(), - new TokenOwnerLookupMisuseHandler(emailManager, properties)); + /** + * Get singleton of misuseMonitor. + *
+ * Note: this has to be a singleton as it tracks (in memory) the number of misuses. + * + * @param emailManager so that the monitors can send e-mails. + * @param properties so that the monitors can look up email settings etc. + * @return gets the singleton of the misuse manager. + */ + @Inject + @Provides + @Singleton + private IMisuseMonitor getMisuseMonitor(final EmailManager emailManager, final PropertiesLoader properties) { + if (null == misuseMonitor) { + misuseMonitor = new InMemoryMisuseMonitor(); + log.info("Creating singleton of MisuseMonitor"); - misuseMonitor.registerHandler(GroupManagerLookupMisuseHandler.class.getSimpleName(), - new GroupManagerLookupMisuseHandler(emailManager, properties)); + // TODO: We should automatically register all handlers that implement this interface using reflection? + // register handlers segue specific handlers + misuseMonitor.registerHandler(TokenOwnerLookupMisuseHandler.class.getSimpleName(), + new TokenOwnerLookupMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(EmailVerificationMisuseHandler.class.getSimpleName(), - new EmailVerificationMisuseHandler()); + misuseMonitor.registerHandler(GroupManagerLookupMisuseHandler.class.getSimpleName(), + new GroupManagerLookupMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(EmailVerificationRequestMisuseHandler.class.getSimpleName(), - new EmailVerificationRequestMisuseHandler()); + misuseMonitor.registerHandler(EmailVerificationMisuseHandler.class.getSimpleName(), + new EmailVerificationMisuseHandler()); - misuseMonitor.registerHandler(PasswordResetByEmailMisuseHandler.class.getSimpleName(), - new PasswordResetByEmailMisuseHandler()); + misuseMonitor.registerHandler(EmailVerificationRequestMisuseHandler.class.getSimpleName(), + new EmailVerificationRequestMisuseHandler()); - misuseMonitor.registerHandler(PasswordResetByIPMisuseHandler.class.getSimpleName(), - new PasswordResetByIPMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(PasswordResetByEmailMisuseHandler.class.getSimpleName(), + new PasswordResetByEmailMisuseHandler()); - misuseMonitor.registerHandler(TeacherPasswordResetMisuseHandler.class.getSimpleName(), - new TeacherPasswordResetMisuseHandler()); + misuseMonitor.registerHandler(PasswordResetByIPMisuseHandler.class.getSimpleName(), + new PasswordResetByIPMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(RegistrationMisuseHandler.class.getSimpleName(), - new RegistrationMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(TeacherPasswordResetMisuseHandler.class.getSimpleName(), + new TeacherPasswordResetMisuseHandler()); - misuseMonitor.registerHandler(SegueLoginByEmailMisuseHandler.class.getSimpleName(), - new SegueLoginByEmailMisuseHandler(properties)); + misuseMonitor.registerHandler(RegistrationMisuseHandler.class.getSimpleName(), + new RegistrationMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(SegueLoginByIPMisuseHandler.class.getSimpleName(), - new SegueLoginByIPMisuseHandler()); + misuseMonitor.registerHandler(SegueLoginByEmailMisuseHandler.class.getSimpleName(), + new SegueLoginByEmailMisuseHandler(properties)); - misuseMonitor.registerHandler(LogEventMisuseHandler.class.getSimpleName(), - new LogEventMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(SegueLoginByIPMisuseHandler.class.getSimpleName(), + new SegueLoginByIPMisuseHandler()); - misuseMonitor.registerHandler(QuestionAttemptMisuseHandler.class.getSimpleName(), - new QuestionAttemptMisuseHandler(properties)); + misuseMonitor.registerHandler(LogEventMisuseHandler.class.getSimpleName(), + new LogEventMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(AnonQuestionAttemptMisuseHandler.class.getSimpleName(), - new AnonQuestionAttemptMisuseHandler()); + misuseMonitor.registerHandler(QuestionAttemptMisuseHandler.class.getSimpleName(), + new QuestionAttemptMisuseHandler(properties)); - misuseMonitor.registerHandler(IPQuestionAttemptMisuseHandler.class.getSimpleName(), - new IPQuestionAttemptMisuseHandler(emailManager, properties)); + misuseMonitor.registerHandler(AnonQuestionAttemptMisuseHandler.class.getSimpleName(), + new AnonQuestionAttemptMisuseHandler()); - misuseMonitor.registerHandler(UserSearchMisuseHandler.class.getSimpleName(), - new UserSearchMisuseHandler()); + misuseMonitor.registerHandler(IPQuestionAttemptMisuseHandler.class.getSimpleName(), + new IPQuestionAttemptMisuseHandler(emailManager, properties)); - misuseMonitor.registerHandler(SendEmailMisuseHandler.class.getSimpleName(), - new SendEmailMisuseHandler()); - } + misuseMonitor.registerHandler(UserSearchMisuseHandler.class.getSimpleName(), + new UserSearchMisuseHandler()); - return misuseMonitor; + misuseMonitor.registerHandler(SendEmailMisuseHandler.class.getSimpleName(), + new SendEmailMisuseHandler()); } - @Provides - @Singleton - @Inject - public static MainObjectMapper getMainMapperInstance() { - return MainObjectMapper.INSTANCE; + return misuseMonitor; + } + + @Provides + @Singleton + @Inject + public static MainObjectMapper getMainMapperInstance() { + return MainObjectMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static ContentMapper getContentMapperInstance() { + return ContentMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static UserMapper getUserMapperInstance() { + return UserMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static EventMapper getEventMapperInstance() { + return EventMapper.INSTANCE; + } + + @Provides + @Singleton + @Inject + public static MiscMapper getMiscMapperInstance() { + return MiscMapper.INSTANCE; + } + + /** + * Get the segue version currently running. Returns the value stored on the module if present or retrieves it from + * the properties if not. + * + * @return the segue version as a string or 'unknown' if it cannot be retrieved + */ + public static String getSegueVersion() { + if (SegueGuiceConfigurationModule.version != null) { + return SegueGuiceConfigurationModule.version; } - - @Provides - @Singleton - @Inject - public static ContentMapper getContentMapperInstance() { - return ContentMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static UserMapper getUserMapperInstance() { - return UserMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static EventMapper getEventMapperInstance() { - return EventMapper.INSTANCE; - } - - @Provides - @Singleton - @Inject - public static MiscMapper getMiscMapperInstance() { - return MiscMapper.INSTANCE; - } - - /** - * Get the segue version currently running. Returns the value stored on the module if present or retrieves it from - * the properties if not. - * - * @return the segue version as a string or 'unknown' if it cannot be retrieved - */ - public static String getSegueVersion() { - if (SegueGuiceConfigurationModule.version != null) { - return SegueGuiceConfigurationModule.version; + String version = "unknown"; + try { + Properties p = new Properties(); + try (InputStream is = SegueGuiceConfigurationModule.class.getResourceAsStream("/version.properties")) { + if (is != null) { + p.load(is); + version = p.getProperty("version", ""); } - String version = "unknown"; - try { - Properties p = new Properties(); - try (InputStream is = SegueGuiceConfigurationModule.class.getResourceAsStream("/version.properties")) { - if (is != null) { - p.load(is); - version = p.getProperty("version", ""); - } - } - } catch (Exception e) { - log.error(e.getMessage()); - } - SegueGuiceConfigurationModule.version = version; - return version; + } + } catch (Exception e) { + log.error(e.getMessage()); } - - /** - * Gets the instance of the postgres connection wrapper. - *
- * Note: This needs to be a singleton as it contains a connection pool. - * - * @param databaseUrl database to connect to. - * @param username port that the mongodb service is running on. - * @param password the name of the database to configure the wrapper to use. - * @return PostgresSqlDb db object preconfigured to work with the segue database. - */ - @Provides - @Singleton - @Inject - private static PostgresSqlDb getPostgresDB(@Named(Constants.POSTGRES_DB_URL) final String databaseUrl, - @Named(Constants.POSTGRES_DB_USER) final String username, - @Named(Constants.POSTGRES_DB_PASSWORD) final String password) { - - if (null == postgresDB) { - postgresDB = new PostgresSqlDb(databaseUrl, username, password); - log.info("Created Singleton of PostgresDb wrapper"); - } - - return postgresDB; + SegueGuiceConfigurationModule.version = version; + return version; + } + + /** + * Gets the instance of the postgres connection wrapper. + *
+ * Note: This needs to be a singleton as it contains a connection pool. + * + * @param databaseUrl database to connect to. + * @param username port that the mongodb service is running on. + * @param password the name of the database to configure the wrapper to use. + * @return PostgresSqlDb db object preconfigured to work with the segue database. + */ + @Provides + @Singleton + @Inject + private static PostgresSqlDb getPostgresDB(@Named(Constants.POSTGRES_DB_URL) final String databaseUrl, + @Named(Constants.POSTGRES_DB_USER) final String username, + @Named(Constants.POSTGRES_DB_PASSWORD) final String password) { + + if (null == postgresDB) { + postgresDB = new PostgresSqlDb(databaseUrl, username, password); + log.info("Created Singleton of PostgresDb wrapper"); } - /** - * Gets the instance of the StatisticsManager. Note: this class is a hack and needs to be refactored.... It is - * currently only a singleton as it keeps a cache. - * - * @param userManager to query user information - * @param logManager to query Log information - * @param schoolManager to query School information - * @param contentManager to query live version information - * @param contentIndex index string for current content version - * @param groupManager so that we can see how many groups we have site wide. - * @param questionManager so that we can see how many questions were answered. - * @param contentSummarizerService to produce content summary objects - * @param userStreaksManager to notify users when their answer streak changes - * @return stats manager - */ - @SuppressWarnings("checkstyle:ParameterNumber") - @Provides - @Singleton - @Inject - private static StatisticsManager getStatsManager(final UserAccountManager userManager, - final ILogManager logManager, final SchoolListReader schoolManager, - final GitContentManager contentManager, - @Named(CONTENT_INDEX) final String contentIndex, - final GroupManager groupManager, - final QuestionManager questionManager, - final ContentSummarizerService contentSummarizerService, - final IUserStreaksManager userStreaksManager) { - - if (null == statsManager) { - statsManager = new StatisticsManager(userManager, logManager, schoolManager, contentManager, contentIndex, - groupManager, questionManager, contentSummarizerService, userStreaksManager); - log.info("Created Singleton of Statistics Manager"); - } - - return statsManager; + return postgresDB; + } + + /** + * Gets the instance of the StatisticsManager. Note: this class is a hack and needs to be refactored.... It is + * currently only a singleton as it keeps a cache. + * + * @param userManager to query user information + * @param logManager to query Log information + * @param schoolManager to query School information + * @param contentManager to query live version information + * @param contentIndex index string for current content version + * @param groupManager so that we can see how many groups we have site wide. + * @param questionManager so that we can see how many questions were answered. + * @param contentSummarizerService to produce content summary objects + * @param userStreaksManager to notify users when their answer streak changes + * @return stats manager + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @Provides + @Singleton + @Inject + private static StatisticsManager getStatsManager(final UserAccountManager userManager, + final ILogManager logManager, final SchoolListReader schoolManager, + final GitContentManager contentManager, + @Named(CONTENT_INDEX) final String contentIndex, + final GroupManager groupManager, + final QuestionManager questionManager, + final ContentSummarizerService contentSummarizerService, + final IUserStreaksManager userStreaksManager) { + + if (null == statsManager) { + statsManager = new StatisticsManager(userManager, logManager, schoolManager, contentManager, contentIndex, + groupManager, questionManager, contentSummarizerService, userStreaksManager); + log.info("Created Singleton of Statistics Manager"); } - static final String CRON_STRING_0200_DAILY = "0 0 2 * * ?"; - static final String CRON_STRING_0230_DAILY = "0 30 2 * * ?"; - static final String CRON_STRING_0700_DAILY = "0 0 7 * * ?"; - static final String CRON_STRING_2000_DAILY = "0 0 20 * * ?"; - static final String CRON_STRING_HOURLY = "0 0 * ? * * *"; - static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0 0/4 ? * * *"; - static final String CRON_GROUP_NAME_SQL_MAINTENANCE = "SQLMaintenance"; - static final String CRON_GROUP_NAME_JAVA_JOB = "JavaJob"; - - @Provides - @Singleton - @Inject - private static SegueJobService getSegueJobService(final PropertiesLoader properties, final PostgresSqlDb database) { - if (null == segueJobService) { - String mailjetKey = properties.getProperty(MAILJET_API_KEY); - String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); - String eventPrePostEmails = properties.getProperty(EVENT_PRE_POST_EMAILS); - boolean eventPrePostEmailsEnabled = - null != eventPrePostEmails && !eventPrePostEmails.isEmpty() && Boolean.parseBoolean(eventPrePostEmails); - - SegueScheduledJob piiSqlJob = new SegueScheduledDatabaseScriptJob( - "PIIDeleteScheduledJob", - CRON_GROUP_NAME_SQL_MAINTENANCE, - "SQL scheduled job that deletes PII", - CRON_STRING_0200_DAILY, "db_scripts/scheduled/pii-delete-task.sql"); - - SegueScheduledJob cleanUpOldAnonymousUsers = new SegueScheduledDatabaseScriptJob( - "cleanAnonymousUsers", - CRON_GROUP_NAME_SQL_MAINTENANCE, - "SQL scheduled job that deletes old AnonymousUsers", - CRON_STRING_0230_DAILY, "db_scripts/scheduled/anonymous-user-clean-up.sql"); - - SegueScheduledJob cleanUpExpiredReservations = new SegueScheduledDatabaseScriptJob( - "cleanUpExpiredReservations", - CRON_GROUP_NAME_SQL_MAINTENANCE, - "SQL scheduled job that deletes expired reservations for the event booking system", - CRON_STRING_0700_DAILY, "db_scripts/scheduled/expired-reservations-clean-up.sql"); - - SegueScheduledJob deleteEventAdditionalBookingInformation = SegueScheduledJob.createCustomJob( - "deleteEventAdditionalBookingInformation", - CRON_GROUP_NAME_JAVA_JOB, - "Delete event additional booking information a given period after an event has taken place", - CRON_STRING_0700_DAILY, - Maps.newHashMap(), - new DeleteEventAdditionalBookingInformationJob() - ); - - SegueScheduledJob deleteEventAdditionalBookingInformationOneYearJob = SegueScheduledJob.createCustomJob( - "deleteEventAdditionalBookingInformationOneYear", - CRON_GROUP_NAME_JAVA_JOB, - "Delete event additional booking information a year after an event has taken place if not already removed", - CRON_STRING_0700_DAILY, - Maps.newHashMap(), - new DeleteEventAdditionalBookingInformationOneYearJob() - ); - - SegueScheduledJob eventReminderEmail = SegueScheduledJob.createCustomJob( - "eventReminderEmail", - CRON_GROUP_NAME_JAVA_JOB, - "Send scheduled reminder emails to events", - CRON_STRING_0700_DAILY, - Maps.newHashMap(), - new EventReminderEmailJob() - ); - - SegueScheduledJob eventFeedbackEmail = SegueScheduledJob.createCustomJob( - "eventFeedbackEmail", - CRON_GROUP_NAME_JAVA_JOB, - "Send scheduled feedback emails to events", - CRON_STRING_2000_DAILY, - Maps.newHashMap(), - new EventFeedbackEmailJob() - ); - - SegueScheduledJob scheduledAssignmentsEmail = SegueScheduledJob.createCustomJob( - "scheduledAssignmentsEmail", - CRON_GROUP_NAME_JAVA_JOB, - "Send scheduled assignment notification emails to groups", - CRON_STRING_HOURLY, - Maps.newHashMap(), - new ScheduledAssignmentsEmailJob() - ); - - SegueScheduledJob syncMailjetUsers = new SegueScheduledSyncMailjetUsersJob( - "syncMailjetUsersJob", - CRON_GROUP_NAME_JAVA_JOB, - "Sync users to mailjet", - CRON_STRING_EVERY_FOUR_HOURS); - - List configuredScheduledJobs = new ArrayList<>(Arrays.asList( - piiSqlJob, - cleanUpOldAnonymousUsers, - cleanUpExpiredReservations, - deleteEventAdditionalBookingInformation, - deleteEventAdditionalBookingInformationOneYearJob, - scheduledAssignmentsEmail - )); - - // Simply removing jobs from configuredScheduledJobs won't de-register them if they - // are currently configured, so the constructor takes a list of jobs to remove too. - List scheduledJobsToRemove = new ArrayList<>(); - - if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { - configuredScheduledJobs.add(syncMailjetUsers); - } else { - scheduledJobsToRemove.add(syncMailjetUsers); - } - - if (eventPrePostEmailsEnabled) { - configuredScheduledJobs.add(eventReminderEmail); - configuredScheduledJobs.add(eventFeedbackEmail); - } else { - scheduledJobsToRemove.add(eventReminderEmail); - scheduledJobsToRemove.add(eventFeedbackEmail); - } - segueJobService = new SegueJobService(database, configuredScheduledJobs, scheduledJobsToRemove); + return statsManager; + } + + static final String CRON_STRING_0200_DAILY = "0 0 2 * * ?"; + static final String CRON_STRING_0230_DAILY = "0 30 2 * * ?"; + static final String CRON_STRING_0700_DAILY = "0 0 7 * * ?"; + static final String CRON_STRING_2000_DAILY = "0 0 20 * * ?"; + static final String CRON_STRING_HOURLY = "0 0 * ? * * *"; + static final String CRON_STRING_EVERY_FOUR_HOURS = "0 0 0/4 ? * * *"; + static final String CRON_GROUP_NAME_SQL_MAINTENANCE = "SQLMaintenance"; + static final String CRON_GROUP_NAME_JAVA_JOB = "JavaJob"; + + @Provides + @Singleton + @Inject + private static SegueJobService getSegueJobService(final PropertiesLoader properties, final PostgresSqlDb database) { + if (null == segueJobService) { + String mailjetKey = properties.getProperty(MAILJET_API_KEY); + String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); + String eventPrePostEmails = properties.getProperty(EVENT_PRE_POST_EMAILS); + boolean eventPrePostEmailsEnabled = + null != eventPrePostEmails && !eventPrePostEmails.isEmpty() && Boolean.parseBoolean(eventPrePostEmails); + + SegueScheduledJob piiSqlJob = new SegueScheduledDatabaseScriptJob( + "PIIDeleteScheduledJob", + CRON_GROUP_NAME_SQL_MAINTENANCE, + "SQL scheduled job that deletes PII", + CRON_STRING_0200_DAILY, "db_scripts/scheduled/pii-delete-task.sql"); + + SegueScheduledJob cleanUpOldAnonymousUsers = new SegueScheduledDatabaseScriptJob( + "cleanAnonymousUsers", + CRON_GROUP_NAME_SQL_MAINTENANCE, + "SQL scheduled job that deletes old AnonymousUsers", + CRON_STRING_0230_DAILY, "db_scripts/scheduled/anonymous-user-clean-up.sql"); + + SegueScheduledJob cleanUpExpiredReservations = new SegueScheduledDatabaseScriptJob( + "cleanUpExpiredReservations", + CRON_GROUP_NAME_SQL_MAINTENANCE, + "SQL scheduled job that deletes expired reservations for the event booking system", + CRON_STRING_0700_DAILY, "db_scripts/scheduled/expired-reservations-clean-up.sql"); + + SegueScheduledJob deleteEventAdditionalBookingInformation = SegueScheduledJob.createCustomJob( + "deleteEventAdditionalBookingInformation", + CRON_GROUP_NAME_JAVA_JOB, + "Delete event additional booking information a given period after an event has taken place", + CRON_STRING_0700_DAILY, + Maps.newHashMap(), + new DeleteEventAdditionalBookingInformationJob() + ); + + SegueScheduledJob deleteEventAdditionalBookingInformationOneYearJob = SegueScheduledJob.createCustomJob( + "deleteEventAdditionalBookingInformationOneYear", + CRON_GROUP_NAME_JAVA_JOB, + "Delete event additional booking information a year after an event has taken place if not already removed", + CRON_STRING_0700_DAILY, + Maps.newHashMap(), + new DeleteEventAdditionalBookingInformationOneYearJob() + ); + + SegueScheduledJob eventReminderEmail = SegueScheduledJob.createCustomJob( + "eventReminderEmail", + CRON_GROUP_NAME_JAVA_JOB, + "Send scheduled reminder emails to events", + CRON_STRING_0700_DAILY, + Maps.newHashMap(), + new EventReminderEmailJob() + ); + + SegueScheduledJob eventFeedbackEmail = SegueScheduledJob.createCustomJob( + "eventFeedbackEmail", + CRON_GROUP_NAME_JAVA_JOB, + "Send scheduled feedback emails to events", + CRON_STRING_2000_DAILY, + Maps.newHashMap(), + new EventFeedbackEmailJob() + ); + + SegueScheduledJob scheduledAssignmentsEmail = SegueScheduledJob.createCustomJob( + "scheduledAssignmentsEmail", + CRON_GROUP_NAME_JAVA_JOB, + "Send scheduled assignment notification emails to groups", + CRON_STRING_HOURLY, + Maps.newHashMap(), + new ScheduledAssignmentsEmailJob() + ); + + SegueScheduledJob syncMailjetUsers = new SegueScheduledSyncMailjetUsersJob( + "syncMailjetUsersJob", + CRON_GROUP_NAME_JAVA_JOB, + "Sync users to mailjet", + CRON_STRING_EVERY_FOUR_HOURS); + + List configuredScheduledJobs = new ArrayList<>(Arrays.asList( + piiSqlJob, + cleanUpOldAnonymousUsers, + cleanUpExpiredReservations, + deleteEventAdditionalBookingInformation, + deleteEventAdditionalBookingInformationOneYearJob, + scheduledAssignmentsEmail + )); + + // Simply removing jobs from configuredScheduledJobs won't de-register them if they + // are currently configured, so the constructor takes a list of jobs to remove too. + List scheduledJobsToRemove = new ArrayList<>(); + + if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { + configuredScheduledJobs.add(syncMailjetUsers); + } else { + scheduledJobsToRemove.add(syncMailjetUsers); + } + + if (eventPrePostEmailsEnabled) { + configuredScheduledJobs.add(eventReminderEmail); + configuredScheduledJobs.add(eventFeedbackEmail); + } else { + scheduledJobsToRemove.add(eventReminderEmail); + scheduledJobsToRemove.add(eventFeedbackEmail); + } + segueJobService = new SegueJobService(database, configuredScheduledJobs, scheduledJobsToRemove); - } - - return segueJobService; } - @Provides - @Singleton - @Inject - private static IExternalAccountManager getExternalAccountManager(final PropertiesLoader properties, - final PostgresSqlDb database) { - - if (null == externalAccountManager) { - String mailjetKey = properties.getProperty(MAILJET_API_KEY); - String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); - - if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { - // If MailJet is configured, initialise the sync: - IExternalAccountDataManager externalAccountDataManager = new PgExternalAccountPersistenceManager(database); - MailJetApiClientWrapper mailJetApiClientWrapper = new MailJetApiClientWrapper(mailjetKey, mailjetSecret, - properties.getProperty(MAILJET_NEWS_LIST_ID), properties.getProperty(MAILJET_EVENTS_LIST_ID), - properties.getProperty(MAILJET_LEGAL_LIST_ID)); - - log.info("Created singleton of ExternalAccountManager."); - externalAccountManager = new ExternalAccountManager(mailJetApiClientWrapper, externalAccountDataManager); - } else { - // Else warn and initialise a placeholder that always throws an error if used: - log.warn("Created stub of ExternalAccountManager since external provider not configured."); - externalAccountManager = new StubExternalAccountManager(); - } - } - return externalAccountManager; + return segueJobService; + } + + @Provides + @Singleton + @Inject + private static IExternalAccountManager getExternalAccountManager(final PropertiesLoader properties, + final PostgresSqlDb database) { + + if (null == externalAccountManager) { + String mailjetKey = properties.getProperty(MAILJET_API_KEY); + String mailjetSecret = properties.getProperty(MAILJET_API_SECRET); + + if (null != mailjetKey && null != mailjetSecret && !mailjetKey.isEmpty() && !mailjetSecret.isEmpty()) { + // If MailJet is configured, initialise the sync: + IExternalAccountDataManager externalAccountDataManager = new PgExternalAccountPersistenceManager(database); + MailJetApiClientWrapper mailJetApiClientWrapper = new MailJetApiClientWrapper(mailjetKey, mailjetSecret, + properties.getProperty(MAILJET_NEWS_LIST_ID), properties.getProperty(MAILJET_EVENTS_LIST_ID), + properties.getProperty(MAILJET_LEGAL_LIST_ID)); + + log.info("Created singleton of ExternalAccountManager."); + externalAccountManager = new ExternalAccountManager(mailJetApiClientWrapper, externalAccountDataManager); + } else { + // Else warn and initialise a placeholder that always throws an error if used: + log.warn("Created stub of ExternalAccountManager since external provider not configured."); + externalAccountManager = new StubExternalAccountManager(); + } } - - /** - * Gets a Game persistence manager. - *
- * This needs to be a singleton as it maintains temporary boards in memory. - * - * @param database the database that persists gameboards. - * @param contentManager api that the game manager can use for content resolution. - * @param mapper an instance of an auto mapper for translating gameboard DOs and DTOs efficiently. - * @param objectMapper a mapper to allow content to be resolved. - * @param uriManager so that we can create content that is aware of its own location - * @return Game persistence manager object. - */ - @Inject - @Provides - @Singleton - private static GameboardPersistenceManager getGameboardPersistenceManager( - final PostgresSqlDb database, - final GitContentManager contentManager, - final MainObjectMapper mapper, - final ObjectMapper objectMapper, - final URIManager uriManager - ) { - if (null == gameboardPersistenceManager) { - gameboardPersistenceManager = new GameboardPersistenceManager(database, contentManager, mapper, - objectMapper, uriManager); - log.info("Creating Singleton of GameboardPersistenceManager"); - } - - return gameboardPersistenceManager; + return externalAccountManager; + } + + /** + * Gets a Game persistence manager. + *
+ * This needs to be a singleton as it maintains temporary boards in memory. + * + * @param database the database that persists gameboards. + * @param contentManager api that the game manager can use for content resolution. + * @param mapper an instance of an auto mapper for translating gameboard DOs and DTOs efficiently. + * @param objectMapper a mapper to allow content to be resolved. + * @param uriManager so that we can create content that is aware of its own location + * @return Game persistence manager object. + */ + @Inject + @Provides + @Singleton + private static GameboardPersistenceManager getGameboardPersistenceManager( + final PostgresSqlDb database, + final GitContentManager contentManager, + final MainObjectMapper mapper, + final ObjectMapper objectMapper, + final URIManager uriManager + ) { + if (null == gameboardPersistenceManager) { + gameboardPersistenceManager = new GameboardPersistenceManager(database, contentManager, mapper, + objectMapper, uriManager); + log.info("Creating Singleton of GameboardPersistenceManager"); } - /** - * Gets an assignment manager. - *
- * This needs to be a singleton because operations like emailing are run for each IGroupObserver, the - * assignment manager should only be one observer. - * - * @param assignmentPersistenceManager to save assignments - * @param groupManager to allow communication with the group manager. - * @param emailService email service - * @param gameManager the game manager object - * @param properties properties loader for the service's hostname - * @return Assignment manager object. - */ - @Inject - @Provides - @Singleton - private static AssignmentManager getAssignmentManager( - final IAssignmentPersistenceManager assignmentPersistenceManager, final GroupManager groupManager, - final EmailService emailService, final GameManager gameManager, final PropertiesLoader properties) { - if (null == assignmentManager) { - assignmentManager = - new AssignmentManager(assignmentPersistenceManager, groupManager, emailService, gameManager, properties); - log.info("Creating Singleton AssignmentManager"); - } - return assignmentManager; + return gameboardPersistenceManager; + } + + /** + * Gets an assignment manager. + *
+ * This needs to be a singleton because operations like emailing are run for each IGroupObserver, the + * assignment manager should only be one observer. + * + * @param assignmentPersistenceManager to save assignments + * @param groupManager to allow communication with the group manager. + * @param emailService email service + * @param gameManager the game manager object + * @param properties properties loader for the service's hostname + * @return Assignment manager object. + */ + @Inject + @Provides + @Singleton + private static AssignmentManager getAssignmentManager( + final IAssignmentPersistenceManager assignmentPersistenceManager, final GroupManager groupManager, + final EmailService emailService, final GameManager gameManager, final PropertiesLoader properties) { + if (null == assignmentManager) { + assignmentManager = + new AssignmentManager(assignmentPersistenceManager, groupManager, emailService, gameManager, properties); + log.info("Creating Singleton AssignmentManager"); } - - /** - * Gets an instance of the symbolic question validator. - * - * @param properties properties loader to get the symbolic validator host - * @return IsaacSymbolicValidator preconfigured to work with the specified checker. - */ - @Provides - @Singleton - @Inject - private static IsaacSymbolicValidator getSymbolicValidator(final PropertiesLoader properties) { - - return new IsaacSymbolicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), - properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); + return assignmentManager; + } + + /** + * Gets an instance of the symbolic question validator. + * + * @param properties properties loader to get the symbolic validator host + * @return IsaacSymbolicValidator preconfigured to work with the specified checker. + */ + @Provides + @Singleton + @Inject + private static IsaacSymbolicValidator getSymbolicValidator(final PropertiesLoader properties) { + + return new IsaacSymbolicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), + properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); + } + + /** + * Gets an instance of the symbolic logic question validator. + * + * @param properties properties loader to get the symbolic logic validator host + * @return IsaacSymbolicLogicValidator preconfigured to work with the specified checker. + */ + @Provides + @Singleton + @Inject + private static IsaacSymbolicLogicValidator getSymbolicLogicValidator(final PropertiesLoader properties) { + + return new IsaacSymbolicLogicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), + properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); + } + + /** + * This provides a singleton of the SchoolListReader for use by segue backed applications.. + *
+ * We want this to be a singleton as otherwise it may not be threadsafe for loading into same SearchProvider. + * + * @param provider The search provider. + * @return schoolList reader + */ + @Inject + @Provides + @Singleton + private SchoolListReader getSchoolListReader(final ISearchProvider provider) { + if (null == schoolListReader) { + schoolListReader = new SchoolListReader(provider); + log.info("Creating singleton of SchoolListReader"); } - - /** - * Gets an instance of the symbolic logic question validator. - * - * @param properties properties loader to get the symbolic logic validator host - * @return IsaacSymbolicLogicValidator preconfigured to work with the specified checker. - */ - @Provides - @Singleton - @Inject - private static IsaacSymbolicLogicValidator getSymbolicLogicValidator(final PropertiesLoader properties) { - - return new IsaacSymbolicLogicValidator(properties.getProperty(Constants.EQUALITY_CHECKER_HOST), - properties.getProperty(Constants.EQUALITY_CHECKER_PORT)); + return schoolListReader; + } + + /** + * Utility method to make the syntax of property bindings clearer. + * + * @param propertyLabel Key for a given property + * @param propertyLoader property loader to use + */ + private void bindConstantToProperty(final String propertyLabel, final PropertiesLoader propertyLoader) { + bindConstant().annotatedWith(Names.named(propertyLabel)).to(propertyLoader.getProperty(propertyLabel)); + } + + /** + * Utility method to get a pre-generated reflections class for the uk.ac.cam.cl.dtg.segue package. + * + * @param pkg class name to use as key + * @return reflections. + */ + public static Set> getPackageClasses(final String pkg) { + return classesByPackage.computeIfAbsent(pkg, key -> { + log.info(String.format("Caching reflections scan on '%s'", key)); + return getClasses(key); + }); + } + + /** + * Gets the segue classes that should be registered as context listeners. + * + * @return the list of context listener classes (these should all be singletons). + */ + public static Collection> getRegisteredContextListenerClasses() { + + if (null == contextListeners) { + contextListeners = Lists.newArrayList(); + + Set> subTypes = + getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue"), ServletContextListener.class); + + Set> etlSubTypes = + getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue.etl"), ServletContextListener.class); + + subTypes.removeAll(etlSubTypes); + + for (Class contextListener : subTypes) { + contextListeners.add(contextListener); + log.info("Registering context listener class {}", contextListener.getCanonicalName()); + } } - /** - * This provides a singleton of the SchoolListReader for use by segue backed applications.. - *
- * We want this to be a singleton as otherwise it may not be threadsafe for loading into same SearchProvider. - * - * @param provider The search provider. - * @return schoolList reader - */ - @Inject - @Provides - @Singleton - private SchoolListReader getSchoolListReader(final ISearchProvider provider) { - if (null == schoolListReader) { - schoolListReader = new SchoolListReader(provider); - log.info("Creating singleton of SchoolListReader"); - } - return schoolListReader; - } - - /** - * Utility method to make the syntax of property bindings clearer. - * - * @param propertyLabel Key for a given property - * @param propertyLoader property loader to use - */ - private void bindConstantToProperty(final String propertyLabel, final PropertiesLoader propertyLoader) { - bindConstant().annotatedWith(Names.named(propertyLabel)).to(propertyLoader.getProperty(propertyLabel)); - } - - /** - * Utility method to get a pre-generated reflections class for the uk.ac.cam.cl.dtg.segue package. - * - * @param pkg class name to use as key - * @return reflections. - */ - public static Set> getPackageClasses(final String pkg) { - return classesByPackage.computeIfAbsent(pkg, key -> { - log.info(String.format("Caching reflections scan on '%s'", key)); - return getClasses(key); - }); - } - - /** - * Gets the segue classes that should be registered as context listeners. - * - * @return the list of context listener classes (these should all be singletons). - */ - public static Collection> getRegisteredContextListenerClasses() { - - if (null == contextListeners) { - contextListeners = Lists.newArrayList(); - - Set> subTypes = - getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue"), ServletContextListener.class); - - Set> etlSubTypes = - getSubTypes(getPackageClasses("uk.ac.cam.cl.dtg.segue.etl"), ServletContextListener.class); - - subTypes.removeAll(etlSubTypes); - - for (Class contextListener : subTypes) { - contextListeners.add(contextListener); - log.info("Registering context listener class {}", contextListener.getCanonicalName()); - } - } - - return contextListeners; - } - - @Override - public void contextInitialized(final ServletContextEvent sce) { - // nothing needed - } - - @Override - public void contextDestroyed(final ServletContextEvent sce) { - // Close all resources we hold. - log.info("Segue Config Module notified of shutdown. Releasing resources"); - try { - elasticSearchClient.close(); - elasticSearchClient = null; - } catch (IOException e) { - log.error("Error releasing Elasticsearch client", e); - } - - postgresDB.close(); - postgresDB = null; - } - - /** - * Factory method for providing a single Guice Injector class. - * - * @return a Guice Injector configured with this SegueGuiceConfigurationModule. - */ - public static synchronized Injector getGuiceInjector() { - if (null == injector) { - injector = Guice.createInjector(new SegueGuiceConfigurationModule()); - } - return injector; + return contextListeners; + } + + @Override + public void contextInitialized(final ServletContextEvent sce) { + // nothing needed + } + + @Override + public void contextDestroyed(final ServletContextEvent sce) { + // Close all resources we hold. + log.info("Segue Config Module notified of shutdown. Releasing resources"); + try { + elasticSearchClient.close(); + elasticSearchClient = null; + } catch (IOException e) { + log.error("Error releasing Elasticsearch client", e); } - @Provides - @Singleton - public static Clock getDefaultClock() { - return Clock.systemUTC(); + postgresDB.close(); + postgresDB = null; + } + + /** + * Factory method for providing a single Guice Injector class. + * + * @return a Guice Injector configured with this SegueGuiceConfigurationModule. + */ + public static synchronized Injector getGuiceInjector() { + if (null == injector) { + injector = Guice.createInjector(new SegueGuiceConfigurationModule()); } + return injector; + } + + @Provides + @Singleton + public static Clock getDefaultClock() { + return Clock.systemUTC(); + } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index 66a12a1109..c421fb9b59 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -37,302 +37,326 @@ public class MailJetApiClientWrapper { - private static final Logger log = LoggerFactory.getLogger(MailJetApiClientWrapper.class); - - private final MailjetClient mailjetClient; - private final String newsListId; - private final String eventsListId; - private final String legalListId; - - /** - * Wrapper for MailjetClient class. - * - * @param mailjetApiKey - MailJet API Key - * @param mailjetApiSecret - MailJet API Client Secret - * @param mailjetNewsListId - MailJet list ID for NEWS_AND_UPDATES - * @param mailjetEventsListId - MailJet list ID for EVENTS - * @param mailjetLegalListId - MailJet list ID for legal notices (all users) - */ - @Inject - public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, final String mailjetNewsListId, final String mailjetEventsListId, final String mailjetLegalListId) { - - if (mailjetApiKey == null || mailjetApiSecret == null) { - throw new IllegalArgumentException("Mailjet API credentials cannot be null"); - } - - ClientOptions options = ClientOptions.builder().apiKey(mailjetApiKey).apiSecretKey(mailjetApiSecret).build(); - - this.mailjetClient = new MailjetClient(options); - this.newsListId = mailjetNewsListId; - this.eventsListId = mailjetEventsListId; - this.legalListId = mailjetLegalListId; + private static final Logger log = LoggerFactory.getLogger(MailJetApiClientWrapper.class); + + private final MailjetClient mailjetClient; + private final String newsListId; + private final String eventsListId; + private final String legalListId; + + /** + * Wrapper for MailjetClient class. + * + * @param mailjetApiKey - MailJet API Key + * @param mailjetApiSecret - MailJet API Client Secret + * @param mailjetNewsListId - MailJet list ID for NEWS_AND_UPDATES + * @param mailjetEventsListId - MailJet list ID for EVENTS + * @param mailjetLegalListId - MailJet list ID for legal notices (all users) + */ + @Inject + public MailJetApiClientWrapper(final String mailjetApiKey, final String mailjetApiSecret, + final String mailjetNewsListId, final String mailjetEventsListId, + final String mailjetLegalListId) { + + if (mailjetApiKey == null || mailjetApiSecret == null) { + throw new IllegalArgumentException("Mailjet API credentials cannot be null"); } - /** - * Get user details for an existing MailJet account. - * - * @param mailjetIdOrEmail - email address or MailJet user ID - * @return JSONObject of the MailJet user, or null if not found - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { - if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { - log.debug("Attempted to get account with null/empty identifier"); - return null; - } + ClientOptions options = ClientOptions.builder().apiKey(mailjetApiKey).apiSecretKey(mailjetApiSecret).build(); + + this.mailjetClient = new MailjetClient(options); + this.newsListId = mailjetNewsListId; + this.eventsListId = mailjetEventsListId; + this.legalListId = mailjetLegalListId; + } + + /** + * Get user details for an existing MailJet account. + * + * @param mailjetIdOrEmail - email address or MailJet user ID + * @return JSONObject of the MailJet user, or null if not found + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws MailjetException { + if (mailjetIdOrEmail == null || mailjetIdOrEmail.trim().isEmpty()) { + log.debug("Attempted to get account with null/empty identifier"); + return null; + } - try { - MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); - MailjetResponse response = mailjetClient.get(request); + try { + MailjetRequest request = new MailjetRequest(Contact.resource, mailjetIdOrEmail); + MailjetResponse response = mailjetClient.get(request); - if (response.getStatus() == 404) { - return null; - } + if (response.getStatus() == 404) { + return null; + } - if (response.getStatus() != 200) { - log.warn("Unexpected Mailjet response status {} when fetching account", response.getStatus()); - throw new MailjetException("Unexpected response status: " + response.getStatus()); - } + if (response.getStatus() != 200) { + log.warn("Unexpected Mailjet response status {} when fetching account", response.getStatus()); + throw new MailjetException("Unexpected response status: " + response.getStatus()); + } - JSONArray responseData = response.getData(); - if (response.getTotal() == 1 && !responseData.isEmpty()) { - return responseData.getJSONObject(0); - } + JSONArray responseData = response.getData(); + if (response.getTotal() == 1 && !responseData.isEmpty()) { + return responseData.getJSONObject(0); + } - return null; + return null; - } catch (MailjetException e) { - if (isNotFoundException(e)) { - return null; - } + } catch (MailjetException e) { + if (isNotFoundException(e)) { + return null; + } - if (isCommunicationException(e)) { - log.error("Communication error fetching Mailjet account", e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } + if (isCommunicationException(e)) { + log.error("Communication error fetching Mailjet account", e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } - log.error("Error fetching Mailjet account", e); - throw e; - } + log.error("Error fetching Mailjet account", e); + throw e; + } + } + + /** + * Perform an asynchronous GDPR-compliant deletion of a MailJet user. + * + * @param mailjetId - MailJet user ID + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetException { + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); } - /** - * Perform an asynchronous GDPR-compliant deletion of a MailJet user. - * - * @param mailjetId - MailJet user ID - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetException { - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); - } - - try { - MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); - MailjetResponse response = mailjetClient.delete(request); - - if (response.getStatus() == 204 || response.getStatus() == 200) { - log.info("Successfully deleted Mailjet account: {}", mailjetId); - } else if (response.getStatus() == 404) { - log.debug("Attempted to delete non-existent Mailjet account: {}", mailjetId); - } else { - log.error("Unexpected response status {} when deleting Mailjet account", response.getStatus()); - throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); - } - - } catch (MailjetException e) { - if (isNotFoundException(e)) { - log.debug("Mailjet account already deleted or not found: {}", mailjetId); - return; - } - - if (isCommunicationException(e)) { - log.error("Communication error deleting Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - - log.error("Error deleting Mailjet account: {}", mailjetId, e); - throw e; - } + try { + MailjetRequest request = new MailjetRequest(Contacts.resource, mailjetId); + MailjetResponse response = mailjetClient.delete(request); + + if (response.getStatus() == 204 || response.getStatus() == 200) { + log.info("Successfully deleted Mailjet account: {}", mailjetId); + } else if (response.getStatus() == 404) { + log.debug("Attempted to delete non-existent Mailjet account: {}", mailjetId); + } else { + log.error("Unexpected response status {} when deleting Mailjet account", response.getStatus()); + throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); + } + + } catch (MailjetException e) { + if (isNotFoundException(e)) { + log.debug("Mailjet account already deleted or not found: {}", mailjetId); + return; + } + + if (isCommunicationException(e)) { + log.error("Communication error deleting Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error deleting Mailjet account: {}", mailjetId, e); + throw e; + } + } + + /** + * Add a new user to MailJet. + *
+ * If the user already exists, find by email as a fallback to ensure idempotence. + * + * @param email - email address + * @return the MailJet user ID, or null on failure + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { + if (email == null || email.trim().isEmpty()) { + log.warn("Attempted to create Mailjet account with null/empty email"); + return null; } - /** - * Add a new user to MailJet. - *
- * If the user already exists, find by email as a fallback to ensure idempotence. - * - * @param email - email address - * @return the MailJet user ID, or null on failure - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public String addNewUserOrGetUserIfExists(final String email) throws MailjetException { - if (email == null || email.trim().isEmpty()) { - log.warn("Attempted to create Mailjet account with null/empty email"); - return null; - } + String normalizedEmail = email.trim().toLowerCase(); - String normalizedEmail = email.trim().toLowerCase(); + try { + MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); + MailjetResponse response = mailjetClient.post(request); - try { - MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); - MailjetResponse response = mailjetClient.post(request); - - if (response.getStatus() == 201 || response.getStatus() == 200) { - JSONObject responseData = response.getData().getJSONObject(0); - String mailjetId = String.valueOf(responseData.get("ID")); - log.info("Successfully created Mailjet account: {}", mailjetId); - return mailjetId; - } - - log.error("Unexpected response status {} when creating Mailjet account", response.getStatus()); - throw new MailjetException("Failed to create account. Status: " + response.getStatus()); - - } catch (MailjetClientRequestException e) { - if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { - log.debug("User already exists in Mailjet, fetching existing account"); - - try { - JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); - if (existingAccount != null) { - String mailjetId = String.valueOf(existingAccount.get("ID")); - log.info("Retrieved existing Mailjet account: {}", mailjetId); - return mailjetId; - } else { - log.error("User reported as existing but couldn't fetch account"); - throw new MailjetException("Account exists but couldn't be retrieved"); - } - } catch (JSONException je) { - log.error("JSON parsing error when retrieving existing account", je); - throw new MailjetException("Failed to parse existing account data", je); - } - } else { - log.error("Failed to create Mailjet account: {}", e.getMessage(), e); - throw new MailjetException("Failed to create account: " + e.getMessage(), e); - } - - } catch (MailjetException e) { - if (isCommunicationException(e)) { - log.error("Communication error creating Mailjet account", e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - - log.error("Error creating Mailjet account", e); - throw e; - - } catch (JSONException e) { - log.error("JSON parsing error when creating account", e); - throw new MailjetException("Failed to parse Mailjet response", e); - } - } + if (response.getStatus() == 201 || response.getStatus() == 200) { + JSONObject responseData = response.getData().getJSONObject(0); + String mailjetId = String.valueOf(responseData.get("ID")); + log.info("Successfully created Mailjet account: {}", mailjetId); + return mailjetId; + } - /** - * Update user details for an existing MailJet account. - * - * @param mailjetId - MailJet user ID - * @param firstName - first name of user for contact details - * @param role - role of user for contact details - * @param emailVerificationStatus - verification status of user for contact details - * @param stage - stages of GCSE or A Level - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public void updateUserProperties(final String mailjetId, final String firstName, final String role, final String emailVerificationStatus, final String stage) throws MailjetException { - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); - } + log.error("Unexpected response status {} when creating Mailjet account", response.getStatus()); + throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + + } catch (MailjetClientRequestException e) { + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { + log.debug("User already exists in Mailjet, fetching existing account"); try { - MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId).property(Contactdata.DATA, new JSONArray().put(new JSONObject().put("Name", "firstname").put("value", firstName != null ? firstName : "")).put(new JSONObject().put("Name", "role").put("value", role != null ? role : "")).put(new JSONObject().put("Name", "verification_status").put("value", emailVerificationStatus != null ? emailVerificationStatus : "")).put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown"))); - - MailjetResponse response = mailjetClient.put(request); - - if (response.getStatus() == 200 && response.getTotal() == 1) { - log.debug("Successfully updated properties for Mailjet account: {}", mailjetId); - } else { - log.error("Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); - throw new MailjetException(String.format("Failed to update user properties. Status: %d, Total: %d", response.getStatus(), response.getTotal())); - } - - } catch (MailjetException e) { - if (isNotFoundException(e)) { - log.error("Mailjet contact not found when updating properties: {}. Contact may have been deleted", mailjetId); - throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); - } - - if (isCommunicationException(e)) { - log.error("Communication error updating properties for Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - - log.error("Error updating properties for Mailjet account: {}", mailjetId, e); - throw e; + JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); + if (existingAccount != null) { + String mailjetId = String.valueOf(existingAccount.get("ID")); + log.info("Retrieved existing Mailjet account: {}", mailjetId); + return mailjetId; + } else { + log.error("User reported as existing but couldn't fetch account"); + throw new MailjetException("Account exists but couldn't be retrieved"); + } + } catch (JSONException je) { + log.error("JSON parsing error when retrieving existing account", je); + throw new MailjetException("Failed to parse existing account data", je); } + } else { + log.error("Failed to create Mailjet account: {}", e.getMessage(), e); + throw new MailjetException("Failed to create account: " + e.getMessage(), e); + } + + } catch (MailjetException e) { + if (isCommunicationException(e)) { + log.error("Communication error creating Mailjet account", e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error creating Mailjet account", e); + throw e; + + } catch (JSONException e) { + log.error("JSON parsing error when creating account", e); + throw new MailjetException("Failed to parse Mailjet response", e); + } + } + + /** + * Update user details for an existing MailJet account. + * + * @param mailjetId - MailJet user ID + * @param firstName - first name of user for contact details + * @param role - role of user for contact details + * @param emailVerificationStatus - verification status of user for contact details + * @param stage - stages of GCSE or A Level + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public void updateUserProperties(final String mailjetId, final String firstName, final String role, + final String emailVerificationStatus, final String stage) throws MailjetException { + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); } - /** - * Update user list subscriptions for an existing MailJet account. - * - * @param mailjetId - MailJet user ID - * @param newsEmails - subscription action to take for news emails - * @param eventsEmails - subscription action to take for events emails - * @throws MailjetException - if underlying MailjetClient throws an exception - */ - public void updateUserSubscriptions(final String mailjetId, final MailJetSubscriptionAction newsEmails, final MailJetSubscriptionAction eventsEmails) throws MailjetException { - - if (mailjetId == null || mailjetId.trim().isEmpty()) { - throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); - } - - if (newsEmails == null || eventsEmails == null) { - throw new IllegalArgumentException("Subscription actions cannot be null"); - } - - try { - MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId).property(ContactManagecontactslists.CONTACTSLISTS, new JSONArray().put(new JSONObject().put(ContactslistImportList.LISTID, legalListId).put(ContactslistImportList.ACTION, MailJetSubscriptionAction.FORCE_SUBSCRIBE.getValue())).put(new JSONObject().put(ContactslistImportList.LISTID, newsListId).put(ContactslistImportList.ACTION, newsEmails.getValue())).put(new JSONObject().put(ContactslistImportList.LISTID, eventsListId).put(ContactslistImportList.ACTION, eventsEmails.getValue()))); - - MailjetResponse response = mailjetClient.post(request); - - if (response.getStatus() == 201 && response.getTotal() == 1) { - log.debug("Successfully updated subscriptions for Mailjet account: {}", mailjetId); - } else { - log.error("Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", mailjetId, response.getStatus(), response.getTotal()); - throw new MailjetException(String.format("Failed to update user subscriptions. Status: %d, Total: %d", response.getStatus(), response.getTotal())); - } - - } catch (MailjetException e) { - if (isNotFoundException(e)) { - log.error("Mailjet contact not found when updating subscriptions: {}. Contact may have been deleted", mailjetId); - throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); - } - - if (isCommunicationException(e)) { - log.error("Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - - log.error("Error updating subscriptions for Mailjet account: {}", mailjetId, e); - throw e; - } + try { + MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId).property(Contactdata.DATA, + new JSONArray().put( + new JSONObject().put("Name", "firstname").put("value", firstName != null ? firstName : "")) + .put(new JSONObject().put("Name", "role").put("value", role != null ? role : "")).put( + new JSONObject().put("Name", "verification_status") + .put("value", emailVerificationStatus != null ? emailVerificationStatus : "")) + .put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown"))); + + MailjetResponse response = mailjetClient.put(request); + + if (response.getStatus() == 200 && response.getTotal() == 1) { + log.debug("Successfully updated properties for Mailjet account: {}", mailjetId); + } else { + log.error("Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", mailjetId, + response.getStatus(), response.getTotal()); + throw new MailjetException( + String.format("Failed to update user properties. Status: %d, Total: %d", response.getStatus(), + response.getTotal())); + } + + } catch (MailjetException e) { + if (isNotFoundException(e)) { + log.error("Mailjet contact not found when updating properties: {}. Contact may have been deleted", mailjetId); + throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); + } + + if (isCommunicationException(e)) { + log.error("Communication error updating properties for Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error updating properties for Mailjet account: {}", mailjetId, e); + throw e; + } + } + + /** + * Update user list subscriptions for an existing MailJet account. + * + * @param mailjetId - MailJet user ID + * @param newsEmails - subscription action to take for news emails + * @param eventsEmails - subscription action to take for events emails + * @throws MailjetException - if underlying MailjetClient throws an exception + */ + public void updateUserSubscriptions(final String mailjetId, final MailJetSubscriptionAction newsEmails, + final MailJetSubscriptionAction eventsEmails) throws MailjetException { + + if (mailjetId == null || mailjetId.trim().isEmpty()) { + throw new IllegalArgumentException("Mailjet ID cannot be null or empty"); } - /** - * Check if exception is a 404 not found error. - */ - private boolean isNotFoundException(MailjetException e) { - if (e.getMessage() == null) { - return false; - } - String msg = e.getMessage().toLowerCase(); - return msg.contains("404") || msg.contains("not found") || msg.contains("object not found"); + if (newsEmails == null || eventsEmails == null) { + throw new IllegalArgumentException("Subscription actions cannot be null"); } - /** - * Check if exception is a communication/timeout error. - */ - private boolean isCommunicationException(MailjetException e) { - if (e.getMessage() == null) { - return false; - } - String msg = e.getMessage().toLowerCase(); - return msg.contains("timeout") || msg.contains("connection"); + try { + MailjetRequest request = new MailjetRequest(ContactManagecontactslists.resource, mailjetId).property( + ContactManagecontactslists.CONTACTSLISTS, new JSONArray().put( + new JSONObject().put(ContactslistImportList.LISTID, legalListId) + .put(ContactslistImportList.ACTION, MailJetSubscriptionAction.FORCE_SUBSCRIBE.getValue())).put( + new JSONObject().put(ContactslistImportList.LISTID, newsListId) + .put(ContactslistImportList.ACTION, newsEmails.getValue())).put( + new JSONObject().put(ContactslistImportList.LISTID, eventsListId) + .put(ContactslistImportList.ACTION, eventsEmails.getValue()))); + + MailjetResponse response = mailjetClient.post(request); + + if (response.getStatus() == 201 && response.getTotal() == 1) { + log.debug("Successfully updated subscriptions for Mailjet account: {}", mailjetId); + } else { + log.error("Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", mailjetId, + response.getStatus(), response.getTotal()); + throw new MailjetException( + String.format("Failed to update user subscriptions. Status: %d, Total: %d", response.getStatus(), + response.getTotal())); + } + + } catch (MailjetException e) { + if (isNotFoundException(e)) { + log.error("Mailjet contact not found when updating subscriptions: {}. Contact may have been deleted", + mailjetId); + throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); + } + + if (isCommunicationException(e)) { + log.error("Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); + throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + } + + log.error("Error updating subscriptions for Mailjet account: {}", mailjetId, e); + throw e; + } + } + + /** + * Check if exception is a 404 not found error. + */ + private boolean isNotFoundException(MailjetException e) { + if (e.getMessage() == null) { + return false; + } + String msg = e.getMessage().toLowerCase(); + return msg.contains("404") || msg.contains("not found") || msg.contains("object not found"); + } + + /** + * Check if exception is a communication/timeout error. + */ + private boolean isCommunicationException(MailjetException e) { + if (e.getMessage() == null) { + return false; } + String msg = e.getMessage().toLowerCase(); + return msg.contains("timeout") || msg.contains("connection"); + } } From 69e303da2ce6195fb4027a46ce0bfc021a21dfa1 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 13:21:17 +0200 Subject: [PATCH 15/22] PATCH 19 --- .../managers/ExternalAccountManagerTest.java | 489 ++++++++++-- ...ExternalAccountPersistenceManagerTest.java | 627 ++++++++++++++-- .../email/MailJetApiClientWrapperTest.java | 707 ++++++++++++++++-- 3 files changed, 1668 insertions(+), 155 deletions(-) diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java index b856d329fc..9897fb8e85 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java @@ -9,6 +9,7 @@ import com.mailjet.client.errors.MailjetClientCommunicationException; import com.mailjet.client.errors.MailjetException; +import com.mailjet.client.errors.MailjetRateLimitException; import java.util.List; import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; @@ -21,70 +22,79 @@ import uk.ac.cam.cl.dtg.segue.dao.users.IExternalAccountDataManager; import uk.ac.cam.cl.dtg.util.email.MailJetApiClientWrapper; import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; class ExternalAccountManagerTest { private ExternalAccountManager externalAccountManager; private IExternalAccountDataManager mockDatabase; - private MailJetApiClientWrapper mailjetApi; + private MailJetApiClientWrapper mockMailjetApi; @BeforeEach public void setUp() { mockDatabase = createMock(IExternalAccountDataManager.class); - mailjetApi = createMock(MailJetApiClientWrapper.class); - externalAccountManager = new ExternalAccountManager(mailjetApi, mockDatabase); + mockMailjetApi = createMock(MailJetApiClientWrapper.class); + externalAccountManager = new ExternalAccountManager(mockMailjetApi, mockDatabase); } @Nested - class SynchroniseChangedUsers { + class SynchroniseChangedUsersTests { + @Test - void synchroniseChangedUsers_newUser() throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { - // New user case: - // - providerUserId (mailjetId) is null - // - We expect to call addNewUserOrGetUserIfExists to create a new Mailjet account + void synchroniseChangedUsers_WithNewUser_ShouldCreateAccount() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, null, "test@example.com", Role.STUDENT, "John", false, - EmailVerificationStatus.VERIFIED, true, false, "gcse" + EmailVerificationStatus.VERIFIED, true, false, "GCSE" ); List changedUsers = List.of(userChanges); expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - expect(mailjetApi.addNewUserOrGetUserIfExists("test@example.com")).andReturn("mailjetId"); - mailjetApi.updateUserProperties("mailjetId", "John", "STUDENT", "VERIFIED", "gcse"); + expect(mockMailjetApi.addNewUserOrGetUserIfExists("test@example.com")).andReturn("mailjetId123"); + mockMailjetApi.updateUserProperties("mailjetId123", "John", "STUDENT", "VERIFIED", "GCSE"); expectLastCall(); - mailjetApi.updateUserSubscriptions("mailjetId", + mockMailjetApi.updateUserSubscriptions("mailjetId123", MailJetSubscriptionAction.FORCE_SUBSCRIBE, MailJetSubscriptionAction.UNSUBSCRIBE); expectLastCall(); - mockDatabase.updateExternalAccount(1L, "mailjetId"); + mockDatabase.updateExternalAccount(1L, "mailjetId123"); expectLastCall(); mockDatabase.updateProviderLastUpdated(1L); expectLastCall(); - replay(mockDatabase, mailjetApi); + replay(mockDatabase, mockMailjetApi); + // Act externalAccountManager.synchroniseChangedUsers(); - verify(mockDatabase, mailjetApi); + // Assert + verify(mockDatabase, mockMailjetApi); } @Test - void synchroniseChangedUsers_existingUser() throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { - // Existing user case: - // - providerUserId (mailjetId) is not null - // - We expect to call getAccountByIdOrEmail to retrieve existing Mailjet account + void synchroniseChangedUsers_WithExistingUser_ShouldUpdateAccount() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, - EmailVerificationStatus.VERIFIED, true, false, "gcse" + EmailVerificationStatus.VERIFIED, true, false, "GCSE" ); List changedUsers = List.of(userChanges); - expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); JSONObject mailjetDetails = new JSONObject(); mailjetDetails.put("Email", "test@example.com"); - expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")).andReturn(mailjetDetails); - mailjetApi.updateUserProperties("existingMailjetId", "John", "STUDENT", "VERIFIED", "gcse"); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")).andReturn(mailjetDetails); + mockMailjetApi.updateUserProperties("existingMailjetId", "John", "STUDENT", "VERIFIED", "GCSE"); expectLastCall(); - mailjetApi.updateUserSubscriptions("existingMailjetId", + mockMailjetApi.updateUserSubscriptions("existingMailjetId", MailJetSubscriptionAction.FORCE_SUBSCRIBE, MailJetSubscriptionAction.UNSUBSCRIBE); expectLastCall(); mockDatabase.updateExternalAccount(1L, "existingMailjetId"); @@ -92,42 +102,204 @@ void synchroniseChangedUsers_existingUser() throws SegueDatabaseException, Mailj mockDatabase.updateProviderLastUpdated(1L); expectLastCall(); - replay(mockDatabase, mailjetApi); + replay(mockDatabase, mockMailjetApi); + // Act externalAccountManager.synchroniseChangedUsers(); - verify(mockDatabase, mailjetApi); + // Assert + verify(mockDatabase, mockMailjetApi); } @Test - void synchroniseChangedUsers_deletedUser() throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + void synchroniseChangedUsers_WithDeletedUser_ShouldDeleteAccount() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", true, - EmailVerificationStatus.VERIFIED, true, false, "gcse" + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + JSONObject mailjetDetails = new JSONObject(); + mailjetDetails.put("Email", "test@example.com"); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")).andReturn(mailjetDetails); + mockMailjetApi.permanentlyDeleteAccountById("existingMailjetId"); + expectLastCall(); + mockDatabase.updateExternalAccount(1L, null); + expectLastCall(); + mockDatabase.updateProviderLastUpdated(1L); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithDeliveryFailed_ShouldUnsubscribeFromAll() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.DELIVERY_FAILED, true, false, "GCSE" ); List changedUsers = List.of(userChanges); + JSONObject mailjetDetails = new JSONObject(); + mailjetDetails.put("Email", "test@example.com"); + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")).andReturn(new JSONObject()); - mailjetApi.permanentlyDeleteAccountById("existingMailjetId"); + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")).andReturn(mailjetDetails); + mockMailjetApi.updateUserSubscriptions("existingMailjetId", + MailJetSubscriptionAction.REMOVE, MailJetSubscriptionAction.REMOVE); + expectLastCall(); + mockDatabase.updateProviderLastUpdated(1L); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithEmailChange_ShouldRecreateAccount() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "existingMailjetId", "newemail@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + JSONObject oldMailjetDetails = new JSONObject(); + oldMailjetDetails.put("Email", "oldemail@example.com"); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")).andReturn(oldMailjetDetails); + mockMailjetApi.permanentlyDeleteAccountById("existingMailjetId"); + expectLastCall(); + expect(mockMailjetApi.addNewUserOrGetUserIfExists("newemail@example.com")).andReturn("newMailjetId"); + mockMailjetApi.updateUserProperties("newMailjetId", "John", "STUDENT", "VERIFIED", "GCSE"); + expectLastCall(); + mockMailjetApi.updateUserSubscriptions("newMailjetId", + MailJetSubscriptionAction.FORCE_SUBSCRIBE, MailJetSubscriptionAction.UNSUBSCRIBE); expectLastCall(); + mockDatabase.updateExternalAccount(1L, "newMailjetId"); + expectLastCall(); + mockDatabase.updateProviderLastUpdated(1L); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithMailjetIdButAccountNotFound_ShouldTreatAsNew() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "nonExistentMailjetId", "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("nonExistentMailjetId")).andReturn(null); mockDatabase.updateExternalAccount(1L, null); expectLastCall(); + expect(mockMailjetApi.addNewUserOrGetUserIfExists("test@example.com")).andReturn("newMailjetId"); + mockMailjetApi.updateUserProperties("newMailjetId", "John", "STUDENT", "VERIFIED", "GCSE"); + expectLastCall(); + mockMailjetApi.updateUserSubscriptions("newMailjetId", + MailJetSubscriptionAction.FORCE_SUBSCRIBE, MailJetSubscriptionAction.UNSUBSCRIBE); + expectLastCall(); + mockDatabase.updateExternalAccount(1L, "newMailjetId"); + expectLastCall(); mockDatabase.updateProviderLastUpdated(1L); expectLastCall(); - replay(mockDatabase, mailjetApi); + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void synchroniseChangedUsers_WithNullOrEmptyEmail_ShouldSkip(String email) + throws SegueDatabaseException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, null, email, Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + + replay(mockDatabase, mockMailjetApi); + // Act externalAccountManager.synchroniseChangedUsers(); - verify(mockDatabase, mailjetApi); + // Assert - No mailjet calls should be made + verify(mockDatabase, mockMailjetApi); } @Test - void synchroniseChangedUsers_mailjetException() + void synchroniseChangedUsers_WithEmptyList_ShouldReturnEarly() + throws SegueDatabaseException, ExternalAccountSynchronisationException { + // Arrange + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(new ArrayList<>()); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithDatabaseException_ShouldThrow() throws SegueDatabaseException { + // Arrange + expect(mockDatabase.getRecentlyChangedRecords()) + .andThrow(new SegueDatabaseException("Database error")); + + replay(mockDatabase); + + // Act & Assert + assertThrows(ExternalAccountSynchronisationException.class, + () -> externalAccountManager.synchroniseChangedUsers()); + + verify(mockDatabase); + } + + @Test + void synchroniseChangedUsers_WithMailjetException_ShouldLogAndContinue() throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { - // Regular MailjetException is caught and logged, not re-thrown - // Only MailjetClientCommunicationException causes the method to throw + // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, EmailVerificationStatus.VERIFIED, true, false, "GCSE" @@ -135,20 +307,22 @@ void synchroniseChangedUsers_mailjetException() List changedUsers = List.of(userChanges); expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")) + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")) .andThrow(new MailjetException("Mailjet error")); - replay(mockDatabase, mailjetApi); + replay(mockDatabase, mockMailjetApi); - // This should NOT throw - regular MailjetException is caught and logged + // Act - Should NOT throw - regular MailjetException is caught and logged externalAccountManager.synchroniseChangedUsers(); - verify(mockDatabase, mailjetApi); + // Assert + verify(mockDatabase, mockMailjetApi); } @Test - void synchroniseChangedUsers_communicationException() throws SegueDatabaseException, MailjetException { - // MailjetClientCommunicationException should cause the method to throw + void synchroniseChangedUsers_WithCommunicationException_ShouldThrow() + throws SegueDatabaseException, MailjetException { + // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, EmailVerificationStatus.VERIFIED, true, false, "GCSE" @@ -156,16 +330,241 @@ void synchroniseChangedUsers_communicationException() throws SegueDatabaseExcept List changedUsers = List.of(userChanges); expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - expect(mailjetApi.getAccountByIdOrEmail("existingMailjetId")) + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")) .andThrow(new MailjetClientCommunicationException("Communication error")); - replay(mockDatabase, mailjetApi); + replay(mockDatabase, mockMailjetApi); - // Communication exceptions should be re-thrown + // Act & Assert assertThrows(ExternalAccountSynchronisationException.class, () -> externalAccountManager.synchroniseChangedUsers()); - verify(mockDatabase, mailjetApi); + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithRateLimitException_ShouldThrow() + throws SegueDatabaseException, MailjetException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "existingMailjetId", "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("existingMailjetId")) + .andThrow(new MailjetRateLimitException("Rate limit exceeded")); + + replay(mockDatabase, mockMailjetApi); + + // Act & Assert + ExternalAccountSynchronisationException exception = assertThrows( + ExternalAccountSynchronisationException.class, + () -> externalAccountManager.synchroniseChangedUsers()); + + assertTrue(exception.getMessage().contains("rate limit")); + + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithDatabaseErrorDuringUpdate_ShouldLogAndContinue() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges user1 = new UserExternalAccountChanges( + 1L, "mailjetId1", "test1@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + UserExternalAccountChanges user2 = new UserExternalAccountChanges( + 2L, "mailjetId2", "test2@example.com", Role.TEACHER, "Jane", false, + EmailVerificationStatus.VERIFIED, false, true, "A Level" + ); + List changedUsers = List.of(user1, user2); + + JSONObject mailjet1 = new JSONObject(); + mailjet1.put("Email", "test1@example.com"); + JSONObject mailjet2 = new JSONObject(); + mailjet2.put("Email", "test2@example.com"); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + + // First user - database error during update + expect(mockMailjetApi.getAccountByIdOrEmail("mailjetId1")).andReturn(mailjet1); + mockMailjetApi.updateUserProperties("mailjetId1", "John", "STUDENT", "VERIFIED", "GCSE"); + expectLastCall(); + mockMailjetApi.updateUserSubscriptions("mailjetId1", + MailJetSubscriptionAction.FORCE_SUBSCRIBE, MailJetSubscriptionAction.UNSUBSCRIBE); + expectLastCall(); + mockDatabase.updateExternalAccount(1L, "mailjetId1"); + expectLastCall().andThrow(new SegueDatabaseException("DB error")); + + // Second user - should still process + expect(mockMailjetApi.getAccountByIdOrEmail("mailjetId2")).andReturn(mailjet2); + mockMailjetApi.updateUserProperties("mailjetId2", "Jane", "TEACHER", "VERIFIED", "A Level"); + expectLastCall(); + mockMailjetApi.updateUserSubscriptions("mailjetId2", + MailJetSubscriptionAction.UNSUBSCRIBE, MailJetSubscriptionAction.FORCE_SUBSCRIBE); + expectLastCall(); + mockDatabase.updateExternalAccount(2L, "mailjetId2"); + expectLastCall(); + mockDatabase.updateProviderLastUpdated(2L); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act - Should not throw, continues processing + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithMultipleUsers_ShouldProcessAll() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges user1 = new UserExternalAccountChanges( + 1L, null, "new@example.com", Role.STUDENT, "Alice", false, + EmailVerificationStatus.VERIFIED, true, true, "GCSE" + ); + UserExternalAccountChanges user2 = new UserExternalAccountChanges( + 2L, "existingId", "existing@example.com", Role.TEACHER, "Bob", false, + EmailVerificationStatus.VERIFIED, false, false, "A Level" + ); + List changedUsers = List.of(user1, user2); + + JSONObject mailjet2 = new JSONObject(); + mailjet2.put("Email", "existing@example.com"); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + + // User 1 - new user + expect(mockMailjetApi.addNewUserOrGetUserIfExists("new@example.com")).andReturn("newId"); + mockMailjetApi.updateUserProperties("newId", "Alice", "STUDENT", "VERIFIED", "GCSE"); + expectLastCall(); + mockMailjetApi.updateUserSubscriptions("newId", + MailJetSubscriptionAction.FORCE_SUBSCRIBE, MailJetSubscriptionAction.FORCE_SUBSCRIBE); + expectLastCall(); + mockDatabase.updateExternalAccount(1L, "newId"); + expectLastCall(); + mockDatabase.updateProviderLastUpdated(1L); + expectLastCall(); + + // User 2 - existing user + expect(mockMailjetApi.getAccountByIdOrEmail("existingId")).andReturn(mailjet2); + mockMailjetApi.updateUserProperties("existingId", "Bob", "TEACHER", "VERIFIED", "A Level"); + expectLastCall(); + mockMailjetApi.updateUserSubscriptions("existingId", + MailJetSubscriptionAction.UNSUBSCRIBE, MailJetSubscriptionAction.UNSUBSCRIBE); + expectLastCall(); + mockDatabase.updateExternalAccount(2L, "existingId"); + expectLastCall(); + mockDatabase.updateProviderLastUpdated(2L); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithUnexpectedError_ShouldLogAndContinue() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "mailjetId", "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("mailjetId")) + .andThrow(new RuntimeException("Unexpected error")); + + replay(mockDatabase, mockMailjetApi); + + // Act - Should not throw, logs error and continues + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithNewUserAndNullMailjetId_ShouldThrow() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, null, "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.addNewUserOrGetUserIfExists("test@example.com")).andReturn(null); + + replay(mockDatabase, mockMailjetApi); + + // Act & Assert + externalAccountManager.synchroniseChangedUsers(); + + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithNewUserAndDeliveryFailed_ShouldSkip() + throws SegueDatabaseException, ExternalAccountSynchronisationException, MailjetException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, null, "test@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.DELIVERY_FAILED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + mockDatabase.updateProviderLastUpdated(1L); + expectLastCall(); + mockDatabase.updateExternalAccount(1L, null); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert - No mailjet API calls should be made + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithNewUserAndDeleted_ShouldSkip() + throws SegueDatabaseException, ExternalAccountSynchronisationException, MailjetException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, null, "test@example.com", Role.STUDENT, "John", true, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + + mockDatabase.updateProviderLastUpdated(1L); + expectLastCall(); + mockDatabase.updateExternalAccount(1L, null); + expectLastCall(); + + replay(mockDatabase, mockMailjetApi); + + // Act + externalAccountManager.synchroniseChangedUsers(); + + // Assert + verify(mockDatabase, mockMailjetApi); } } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java index e076c1f3cb..6ce73c1a11 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java @@ -5,72 +5,601 @@ import static org.easymock.EasyMock.replay; import static org.easymock.EasyMock.verify; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.sql.ResultSet; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import uk.ac.cam.cl.dtg.isaac.dos.users.EmailVerificationStatus; -import uk.ac.cam.cl.dtg.isaac.dos.users.Role; import uk.ac.cam.cl.dtg.isaac.dos.users.UserExternalAccountChanges; +import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; +import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; import uk.ac.cam.cl.dtg.util.ReflectionUtils; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.sql.*; +import java.util.List; + +import static org.easymock.EasyMock.*; +import static org.junit.jupiter.api.Assertions.*; class PgExternalAccountPersistenceManagerTest { private PgExternalAccountPersistenceManager persistenceManager; + private PostgresSqlDb mockDatabase; + private Connection mockConnection; + private PreparedStatement mockPreparedStatement; private ResultSet mockResultSet; @BeforeEach void setUp() { - persistenceManager = new PgExternalAccountPersistenceManager(null); + mockDatabase = createMock(PostgresSqlDb.class); + mockConnection = createMock(Connection.class); + mockPreparedStatement = createMock(PreparedStatement.class); mockResultSet = createMock(ResultSet.class); + persistenceManager = new PgExternalAccountPersistenceManager(mockDatabase); } - @Test - void buildUserExternalAccountChanges_ShouldParseRegisteredContextsCorrectly() throws Exception { - // Arrange - // The method expects a JSON ARRAY (from PostgreSQL array_to_json(JSONB[])) - String registeredContextsJson = "[{\"stage\": \"gcse\", \"examBoard\": \"ocr\"}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("providerId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); // wasNull() after getBoolean("news_emails") - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); // wasNull() after getBoolean("events_emails") - expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContextsJson); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[]{ResultSet.class}, - new Object[]{mockResultSet} - ); - - // Assert - verify(mockResultSet); // Verify all expected calls were made - assertNotNull(result); - assertEquals(1L, result.getUserId()); - assertEquals("providerId", result.getProviderUserId()); - assertEquals("test@example.com", result.getAccountEmail()); - assertEquals(Role.STUDENT, result.getRole()); - assertEquals("John", result.getGivenName()); - assertFalse(result.isDeleted()); - assertEquals(EmailVerificationStatus.VERIFIED, result.getEmailVerificationStatus()); - assertTrue(result.allowsNewsEmails()); - assertFalse(result.allowsEventsEmails()); - - // Verify JSON parsing and stage extraction - assertEquals("gcse".toUpperCase(), result.getStage()); + @Nested + class GetRecentlyChangedRecordsTests { + + @Test + void getRecentlyChangedRecords_WithValidData_ShouldReturnUserList() throws Exception { + // Arrange + String registeredContextsJson = "[{\"stage\": \"gcse\"}]"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + expect(mockPreparedStatement.executeQuery()).andReturn(mockResultSet); + + expect(mockResultSet.next()).andReturn(true).once(); + expect(mockResultSet.next()).andReturn(false).once(); + + setupMockResultSetForUser(1L, "mailjetId123", "test@example.com", "STUDENT", + "John", false, "VERIFIED", registeredContextsJson, true, false, false, false); + + mockResultSet.close(); + expectLastCall(); + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + + // Act + List result = persistenceManager.getRecentlyChangedRecords(); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + assertEquals(1, result.size()); + UserExternalAccountChanges user = result.get(0); + assertEquals(1L, user.getUserId()); + assertEquals("mailjetId123", user.getProviderUserId()); + assertEquals("test@example.com", user.getAccountEmail()); + assertEquals("GCSE", user.getStage()); + } + + @Test + void getRecentlyChangedRecords_WithEmptyResults_ShouldReturnEmptyList() throws Exception { + // Arrange + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + expect(mockPreparedStatement.executeQuery()).andReturn(mockResultSet); + expect(mockResultSet.next()).andReturn(false); + + mockResultSet.close(); + expectLastCall(); + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + + // Act + List result = persistenceManager.getRecentlyChangedRecords(); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + assertTrue(result.isEmpty()); + } + + @Test + void getRecentlyChangedRecords_WithDatabaseError_ShouldThrowException() throws Exception { + // Arrange + expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("Connection failed")); + + replay(mockDatabase); + + // Act & Assert + assertThrows(SegueDatabaseException.class, + () -> persistenceManager.getRecentlyChangedRecords()); + + verify(mockDatabase); + } + + @Nested + class UpdateProviderLastUpdatedTests { + + @Test + void updateProviderLastUpdated_WithValidUserId_ShouldUpdateTimestamp() throws Exception { + // Arrange + Long userId = 123L; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); + expectLastCall(); + mockPreparedStatement.setLong(2, userId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateProviderLastUpdated(userId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateProviderLastUpdated_WithNonExistentUser_ShouldLogWarning() throws Exception { + // Arrange + Long userId = 999L; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); + expectLastCall(); + mockPreparedStatement.setLong(2, userId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(0); // No rows updated + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act - Should not throw, just log warning + persistenceManager.updateProviderLastUpdated(userId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateProviderLastUpdated_WithNullUserId_ShouldThrowException() { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> persistenceManager.updateProviderLastUpdated(null)); + } + + @Test + void updateProviderLastUpdated_WithDatabaseError_ShouldThrowException() throws Exception { + // Arrange + Long userId = 123L; + expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); + + replay(mockDatabase); + + // Act & Assert + assertThrows(SegueDatabaseException.class, + () -> persistenceManager.updateProviderLastUpdated(userId)); + + verify(mockDatabase); + } + } + + @Nested + class UpdateExternalAccountTests { + + @Test + void updateExternalAccount_WithNewAccount_ShouldInsert() throws Exception { + // Arrange + Long userId = 123L; + String mailjetId = "mailjet456"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, mailjetId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateExternalAccount(userId, mailjetId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithExistingAccount_ShouldUpdate() throws Exception { + // Arrange + Long userId = 123L; + String mailjetId = "newMailjetId"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, mailjetId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateExternalAccount(userId, mailjetId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithNullMailjetId_ShouldClearAccount() throws Exception { + // Arrange + Long userId = 123L; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, null); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateExternalAccount(userId, null); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithNullUserId_ShouldThrowException() { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> persistenceManager.updateExternalAccount(null, "mailjetId")); + } + + @Test + void updateExternalAccount_WithZeroRowsAffected_ShouldLogWarning() throws Exception { + // Arrange + Long userId = 123L; + String mailjetId = "mailjet456"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, mailjetId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(0); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act - Should not throw, just log warning + persistenceManager.updateExternalAccount(userId, mailjetId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithDatabaseError_ShouldThrowException() throws Exception { + // Arrange + Long userId = 123L; + expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); + + replay(mockDatabase); + + // Act & Assert + assertThrows(SegueDatabaseException.class, + () -> persistenceManager.updateExternalAccount(userId, "mailjetId")); + + verify(mockDatabase); + } + } + + @Nested + class StageExtractionTests { + + @ParameterizedTest + @CsvSource({ + "'[{\"stage\": \"gcse\"}]', GCSE", + "'[{\"stage\": \"a_level\"}]', A Level", + "'[{\"stage\": \"a level\"}]', A Level", + "'[{\"stage\": \"alevel\"}]', A Level", + "'[{\"stage\": \"gcse_and_a_level\"}]', GCSE and A Level", + "'[{\"stage\": \"both\"}]', GCSE and A Level", + "'[{\"stage\": \"all\"}]', ALL" + }) + void extractStage_WithValidStageValues_ShouldNormalizeCorrectly(String json, String expected) throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(json); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals(expected, result.getStage()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"[]", "null", " "}) + void extractStage_WithEmptyOrNullContext_ShouldReturnUnknown(String json) throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(json); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("unknown", result.getStage()); + } + + @Test + void extractStage_WithInvalidJson_ShouldReturnUnknown() throws Exception { + // Arrange + String invalidJson = "[{not valid json}]"; + + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(invalidJson); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("unknown", result.getStage()); + } + + @Test + void extractStage_WithMissingStageKey_ShouldUseFallbackDetection() throws Exception { + // Arrange - JSON without explicit 'stage' key but contains stage text + String jsonWithoutStageKey = "[{\"examBoard\": \"aqa\", \"other\": \"gcse\"}]"; + + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(jsonWithoutStageKey); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("GCSE", result.getStage()); // Fallback should detect "gcse" in the text + } + + @Test + void extractStage_WithUnexpectedStageValue_ShouldReturnUnknown() throws Exception { + // Arrange + String unexpectedStage = "[{\"stage\": \"university\"}]"; + + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(unexpectedStage); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("unknown", result.getStage()); + } + } + + @Nested + class BooleanPreferenceTests { + + @Test + void parsePreference_WithTrueValue_ShouldReturnTrue() throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); // Not null + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertTrue(result.allowsNewsEmails()); + } + + @Test + void parsePreference_WithNullValue_ShouldReturnNull() throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(true); // Was null + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(true); // Was null + + expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertNull(result.allowsNewsEmails()); + assertNull(result.allowsEventsEmails()); + } + } + + // Helper method to setup mock ResultSet with all expected calls + private void setupMockResultSetForUser(Long userId, String mailjetId, String email, String role, + String givenName, boolean deleted, String verificationStatus, + String registeredContexts, boolean newsEmails, boolean eventsEmails, + boolean newsWasNull, boolean eventsWasNull) throws SQLException { + expect(mockResultSet.getLong("id")).andReturn(userId); + expect(mockResultSet.getString("provider_user_identifier")).andReturn(mailjetId); + expect(mockResultSet.getString("email")).andReturn(email); + expect(mockResultSet.getString("role")).andReturn(role); + expect(mockResultSet.getString("given_name")).andReturn(givenName); + expect(mockResultSet.getBoolean("deleted")).andReturn(deleted); + expect(mockResultSet.getString("email_verification_status")).andReturn(verificationStatus); + expect(mockResultSet.getBoolean("news_emails")).andReturn(newsEmails); + expect(mockResultSet.wasNull()).andReturn(newsWasNull); + expect(mockResultSet.getBoolean("events_emails")).andReturn(eventsEmails); + expect(mockResultSet.wasNull()).andReturn(eventsWasNull); + expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContexts); + } } } \ No newline at end of file diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java index bec4934c00..77743a6bee 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java @@ -1,23 +1,24 @@ package uk.ac.cam.cl.dtg.segue.util.email; -import static org.easymock.EasyMock.anyObject; -import static org.easymock.EasyMock.createMock; -import static org.easymock.EasyMock.expect; -import static org.easymock.EasyMock.replay; -import static org.easymock.EasyMock.verify; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - import com.mailjet.client.MailjetClient; import com.mailjet.client.MailjetRequest; import com.mailjet.client.MailjetResponse; +import com.mailjet.client.errors.MailjetClientRequestException; import com.mailjet.client.errors.MailjetException; +import com.mailjet.client.errors.MailjetClientCommunicationException; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import uk.ac.cam.cl.dtg.util.email.MailJetApiClientWrapper; +import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; + +import static org.easymock.EasyMock.*; +import static org.junit.jupiter.api.Assertions.*; class MailJetApiClientWrapperTest { @@ -34,62 +35,646 @@ void setUp() { injectMockMailjetClient(mailJetApiClientWrapper, mockMailjetClient); } - @Test - void getAccountByIdOrEmail_WithValidInput_ShouldReturnAccount() throws MailjetException { - // Arrange - String mailjetId = "123"; - MailjetResponse mockResponse = createMock(MailjetResponse.class); - JSONArray mockData = new JSONArray(); - JSONObject mockAccount = new JSONObject(); - mockAccount.put("ID", 123); - mockData.put(mockAccount); - - expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(mockResponse); - // The code checks getStatus() twice: once for 404, once for 200 - expect(mockResponse.getStatus()).andReturn(200); - expect(mockResponse.getStatus()).andReturn(200); - expect(mockResponse.getTotal()).andReturn(1); - expect(mockResponse.getData()).andReturn(mockData); - - replay(mockMailjetClient, mockResponse); - - // Act - JSONObject result = mailJetApiClientWrapper.getAccountByIdOrEmail(mailjetId); - - // Assert - verify(mockMailjetClient, mockResponse); - assertNotNull(result); - assertEquals(123, result.getInt("ID")); + @Nested + class ConstructorTests { + + @Test + void constructor_WithNullApiKey_ShouldThrowException() { + assertThrows(IllegalArgumentException.class, + () -> new MailJetApiClientWrapper(null, "secret", "news", "events", "legal")); + } + + @Test + void constructor_WithNullApiSecret_ShouldThrowException() { + assertThrows(IllegalArgumentException.class, + () -> new MailJetApiClientWrapper("key", null, "news", "events", "legal")); + } + + @Test + void constructor_WithValidCredentials_ShouldInitialize() { + assertDoesNotThrow(() -> + new MailJetApiClientWrapper("key", "secret", "news", "events", "legal")); + } + } + + @Nested + class GetAccountByIdOrEmailTests { + + @Test + void getAccountByIdOrEmail_WithValidId_ShouldReturnAccount() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + JSONArray mockData = new JSONArray(); + JSONObject mockAccount = new JSONObject(); + mockAccount.put("ID", 123); + mockAccount.put("Email", "test@example.com"); + mockData.put(mockAccount); + + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(200).times(2); + expect(mockResponse.getTotal()).andReturn(1); + expect(mockResponse.getData()).andReturn(mockData); + + replay(mockMailjetClient, mockResponse); + + // Act + JSONObject result = mailJetApiClientWrapper.getAccountByIdOrEmail(mailjetId); + + // Assert + verify(mockMailjetClient, mockResponse); + assertNotNull(result); + assertEquals(123, result.getInt("ID")); + } + + @Test + void getAccountByIdOrEmail_WithNotFound_ShouldReturnNull() throws MailjetException { + // Arrange + String mailjetId = "999"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(404); + + replay(mockMailjetClient, mockResponse); + + // Act + JSONObject result = mailJetApiClientWrapper.getAccountByIdOrEmail(mailjetId); + + // Assert + verify(mockMailjetClient, mockResponse); + assertNull(result); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void getAccountByIdOrEmail_WithNullOrEmptyId_ShouldReturnNull(String input) throws MailjetException { + // Act + JSONObject result = mailJetApiClientWrapper.getAccountByIdOrEmail(input); + + // Assert + assertNull(result); + } + + @Test + void getAccountByIdOrEmail_WithUnexpectedStatus_ShouldThrowException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + + replay(mockMailjetClient, mockResponse); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.getAccountByIdOrEmail(mailjetId)); + + verify(mockMailjetClient, mockResponse); + } + + @Test + void getAccountByIdOrEmail_WithCommunicationError_ShouldThrowCommunicationException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetException timeoutException = new MailjetClientCommunicationException("Timeout occurred"); + + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andThrow(timeoutException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetClientCommunicationException.class, + () -> mailJetApiClientWrapper.getAccountByIdOrEmail(mailjetId)); + + verify(mockMailjetClient); + } + + @Test + void getAccountByIdOrEmail_WithEmptyResponse_ShouldReturnNull() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + JSONArray emptyData = new JSONArray(); + + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(200).times(2); + expect(mockResponse.getTotal()).andReturn(0); + expect(mockResponse.getData()).andReturn(emptyData); + + replay(mockMailjetClient, mockResponse); + + // Act + JSONObject result = mailJetApiClientWrapper.getAccountByIdOrEmail(mailjetId); + + // Assert + verify(mockMailjetClient, mockResponse); + assertNull(result); + } + } + + @Nested + class PermanentlyDeleteAccountTests { + + @Test + void permanentlyDeleteAccount_WithValidId_ShouldDelete() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.delete(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(204); + + replay(mockMailjetClient, mockResponse); + + // Act + mailJetApiClientWrapper.permanentlyDeleteAccountById(mailjetId); + + // Assert + verify(mockMailjetClient, mockResponse); + } + + @Test + void permanentlyDeleteAccount_WithStatus200_ShouldSucceed() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.delete(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(200); + expect(mockResponse.getStatus()).andReturn(200); + + replay(mockMailjetClient, mockResponse); + + // Act + mailJetApiClientWrapper.permanentlyDeleteAccountById(mailjetId); + + // Assert + verify(mockMailjetClient, mockResponse); + } + + @Test + void permanentlyDeleteAccount_WithNotFound_ShouldNotThrow() throws MailjetException { + // Arrange + String mailjetId = "999"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.delete(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(200); + expect(mockResponse.getStatus()).andReturn(200); + + replay(mockMailjetClient, mockResponse); + + // Act - Should not throw + mailJetApiClientWrapper.permanentlyDeleteAccountById(mailjetId); + + // Assert + verify(mockMailjetClient, mockResponse); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void permanentlyDeleteAccount_WithNullOrEmptyId_ShouldThrowException(String input) { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> mailJetApiClientWrapper.permanentlyDeleteAccountById(input)); + } + + @Test + void permanentlyDeleteAccount_WithUnexpectedStatus_ShouldThrowException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.delete(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + + replay(mockMailjetClient, mockResponse); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.permanentlyDeleteAccountById(mailjetId)); + + verify(mockMailjetClient, mockResponse); + } + + @Test + void permanentlyDeleteAccount_WithCommunicationError_ShouldThrowCommunicationException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetException connectionException = new MailjetException("Connection refused"); + + expect(mockMailjetClient.delete(anyObject(MailjetRequest.class))).andThrow(connectionException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetClientCommunicationException.class, + () -> mailJetApiClientWrapper.permanentlyDeleteAccountById(mailjetId)); + + verify(mockMailjetClient); + } + + @Test + void permanentlyDeleteAccount_WithNotFoundException_ShouldNotThrow() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetException notFoundException = new MailjetException("Object not found (404)"); + + expect(mockMailjetClient.delete(anyObject(MailjetRequest.class))).andThrow(notFoundException); + + replay(mockMailjetClient); + + // Act - Should not throw + assertDoesNotThrow(() -> mailJetApiClientWrapper.permanentlyDeleteAccountById(mailjetId)); + + verify(mockMailjetClient); + } + } + + @Nested + class AddNewUserOrGetUserIfExistsTests { + + @Test + void addNewUser_WithNewEmail_ShouldReturnNewId() throws MailjetException { + // Arrange + String email = "test@example.com"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + JSONArray mockData = new JSONArray(); + JSONObject mockUser = new JSONObject(); + mockUser.put("ID", 456); + mockData.put(mockUser); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(201); + expect(mockResponse.getData()).andReturn(mockData); + + replay(mockMailjetClient, mockResponse); + + // Act + String result = mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email); + + // Assert + verify(mockMailjetClient, mockResponse); + assertEquals("456", result); + } + + @Test + void addNewUser_WithExistingEmail_ShouldFetchAndReturnId() throws MailjetException { + // Arrange + String email = "existing@example.com"; + MailjetResponse postResponse = createMock(MailjetResponse.class); + MailjetResponse getResponse = createMock(MailjetResponse.class); + + JSONArray getData = new JSONArray(); + JSONObject existingUser = new JSONObject(); + existingUser.put("ID", 789); + existingUser.put("Email", email); + getData.put(existingUser); + + // Simulate "already exists" error on POST + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))) + .andThrow(new MailjetClientRequestException("Email already exists", 1)); + + // Then successful GET to retrieve existing account + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(getResponse); + expect(getResponse.getStatus()).andReturn(200).times(2); + expect(getResponse.getTotal()).andReturn(1); + expect(getResponse.getData()).andReturn(getData); + + replay(mockMailjetClient, postResponse, getResponse); + + // Act + String result = mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email); + + // Assert + verify(mockMailjetClient, getResponse); + assertEquals("789", result); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void addNewUser_WithNullOrEmptyEmail_ShouldReturnNull(String input) throws MailjetException { + // Act + String result = mailJetApiClientWrapper.addNewUserOrGetUserIfExists(input); + + // Assert + assertNull(result); + } + + @Test + void addNewUser_WithEmailNormalization_ShouldTrimAndLowercase() throws MailjetException { + // Arrange + String email = " Test@EXAMPLE.COM "; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + JSONArray mockData = new JSONArray(); + JSONObject mockUser = new JSONObject(); + mockUser.put("ID", 999); + mockData.put(mockUser); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(201); + expect(mockResponse.getData()).andReturn(mockData); + + replay(mockMailjetClient, mockResponse); + + // Act + String result = mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email); + + // Assert + verify(mockMailjetClient, mockResponse); + assertEquals("999", result); + } + + @Test + void addNewUser_WithUnexpectedStatus_ShouldThrowException() throws MailjetException { + // Arrange + String email = "test@example.com"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + + replay(mockMailjetClient, mockResponse); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email)); + + verify(mockMailjetClient, mockResponse); + } + + @Test + void addNewUser_WithExistingButCannotFetch_ShouldThrowException() throws MailjetException { + // Arrange + String email = "existing@example.com"; + MailjetResponse getResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))) + .andThrow(new MailjetClientRequestException("Email already exists", 1)); + + expect(mockMailjetClient.get(anyObject(MailjetRequest.class))).andReturn(getResponse); + expect(getResponse.getStatus()).andReturn(404); + + replay(mockMailjetClient, getResponse); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email)); + + verify(mockMailjetClient, getResponse); + } + + @Test + void addNewUser_WithCommunicationError_ShouldThrowCommunicationException() throws MailjetException { + // Arrange + String email = "test@example.com"; + MailjetException timeoutException = new MailjetException("Connection timeout"); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andThrow(timeoutException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetClientCommunicationException.class, + () -> mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email)); + + verify(mockMailjetClient); + } } - @Test - void addNewUserOrGetUserIfExists_WithNewEmail_ShouldReturnNewId() throws MailjetException { - // Arrange - String email = "test@example.com"; - MailjetResponse mockResponse = createMock(MailjetResponse.class); - JSONArray mockData = new JSONArray(); - JSONObject mockUser = new JSONObject(); - mockUser.put("ID", 456); - mockData.put(mockUser); - - expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); - // The code checks getStatus() first, then getData() - expect(mockResponse.getStatus()).andReturn(201); - expect(mockResponse.getData()).andReturn(mockData); - - replay(mockMailjetClient, mockResponse); - - // Act - String result = mailJetApiClientWrapper.addNewUserOrGetUserIfExists(email); - - // Assert - verify(mockMailjetClient, mockResponse); - assertEquals("456", result); + @Nested + class UpdateUserPropertiesTests { + + @Test + void updateUserProperties_WithValidData_ShouldUpdate() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.put(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(200); + expect(mockResponse.getTotal()).andReturn(1); + + replay(mockMailjetClient, mockResponse); + + // Act + mailJetApiClientWrapper.updateUserProperties(mailjetId, "John", "STUDENT", "VERIFIED", "GCSE"); + + // Assert + verify(mockMailjetClient, mockResponse); + } + + @Test + void updateUserProperties_WithNullValues_ShouldUseEmptyStrings() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.put(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(200); + expect(mockResponse.getTotal()).andReturn(1); + + replay(mockMailjetClient, mockResponse); + + // Act + mailJetApiClientWrapper.updateUserProperties(mailjetId, null, null, null, null); + + // Assert + verify(mockMailjetClient, mockResponse); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void updateUserProperties_WithNullOrEmptyId_ShouldThrowException(String input) { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> mailJetApiClientWrapper.updateUserProperties(input, "John", "STUDENT", "VERIFIED", "GCSE")); + } + + @Test + void updateUserProperties_WithNotFound_ShouldThrowException() throws MailjetException { + // Arrange + String mailjetId = "999"; + MailjetException notFoundException = new MailjetException("Contact not found (404)"); + + expect(mockMailjetClient.put(anyObject(MailjetRequest.class))).andThrow(notFoundException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.updateUserProperties(mailjetId, "John", "STUDENT", "VERIFIED", "GCSE")); + + verify(mockMailjetClient); + } + + @Test + void updateUserProperties_WithUnexpectedStatus_ShouldThrowException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.put(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getTotal()).andReturn(0); + expect(mockResponse.getTotal()).andReturn(0); + + replay(mockMailjetClient, mockResponse); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.updateUserProperties(mailjetId, "John", "STUDENT", "VERIFIED", "GCSE")); + + verify(mockMailjetClient, mockResponse); + } + + @Test + void updateUserProperties_WithCommunicationError_ShouldThrowCommunicationException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetException connectionException = new MailjetException("Connection timeout"); + + expect(mockMailjetClient.put(anyObject(MailjetRequest.class))).andThrow(connectionException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetClientCommunicationException.class, + () -> mailJetApiClientWrapper.updateUserProperties(mailjetId, "John", "STUDENT", "VERIFIED", "GCSE")); + + verify(mockMailjetClient); + } } - @Test - void addNewUserOrGetUserIfExists_WithNullEmail_ShouldReturnNull() throws MailjetException { - assertNull(mailJetApiClientWrapper.addNewUserOrGetUserIfExists(null)); + @Nested + class UpdateUserSubscriptionsTests { + + @Test + void updateUserSubscriptions_WithValidData_ShouldUpdate() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(201); + expect(mockResponse.getTotal()).andReturn(1); + + replay(mockMailjetClient, mockResponse); + + // Act + mailJetApiClientWrapper.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.FORCE_SUBSCRIBE, + MailJetSubscriptionAction.UNSUBSCRIBE); + + // Assert + verify(mockMailjetClient, mockResponse); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void updateUserSubscriptions_WithNullOrEmptyId_ShouldThrowException(String input) { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> mailJetApiClientWrapper.updateUserSubscriptions(input, + MailJetSubscriptionAction.FORCE_SUBSCRIBE, + MailJetSubscriptionAction.UNSUBSCRIBE)); + } + + @Test + void updateUserSubscriptions_WithNullNewsAction_ShouldThrowException() { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> mailJetApiClientWrapper.updateUserSubscriptions("123", null, + MailJetSubscriptionAction.UNSUBSCRIBE)); + } + + @Test + void updateUserSubscriptions_WithNullEventsAction_ShouldThrowException() { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> mailJetApiClientWrapper.updateUserSubscriptions("123", + MailJetSubscriptionAction.FORCE_SUBSCRIBE, null)); + } + + @Test + void updateUserSubscriptions_WithNotFound_ShouldThrowException() throws MailjetException { + // Arrange + String mailjetId = "999"; + MailjetException notFoundException = new MailjetException("Contact not found (404)"); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andThrow(notFoundException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.FORCE_SUBSCRIBE, + MailJetSubscriptionAction.UNSUBSCRIBE)); + + verify(mockMailjetClient); + } + + @Test + void updateUserSubscriptions_WithUnexpectedStatus_ShouldThrowException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetResponse mockResponse = createMock(MailjetResponse.class); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getStatus()).andReturn(500); + expect(mockResponse.getTotal()).andReturn(500); + expect(mockResponse.getTotal()).andReturn(500); + replay(mockMailjetClient, mockResponse); + + // Act & Assert + assertThrows(MailjetException.class, + () -> mailJetApiClientWrapper.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.FORCE_SUBSCRIBE, + MailJetSubscriptionAction.UNSUBSCRIBE)); + + verify(mockMailjetClient, mockResponse); + } + + @Test + void updateUserSubscriptions_WithCommunicationError_ShouldThrowCommunicationException() throws MailjetException { + // Arrange + String mailjetId = "123"; + MailjetException timeoutException = new MailjetException("Timeout occurred"); + + expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andThrow(timeoutException); + + replay(mockMailjetClient); + + // Act & Assert + assertThrows(MailjetClientCommunicationException.class, + () -> mailJetApiClientWrapper.updateUserSubscriptions(mailjetId, + MailJetSubscriptionAction.FORCE_SUBSCRIBE, + MailJetSubscriptionAction.UNSUBSCRIBE)); + + verify(mockMailjetClient); + } } private void injectMockMailjetClient(MailJetApiClientWrapper wrapper, MailjetClient client) { From acef1390fe5c91efaf0af8c1c9968daf56cd739f Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 13:42:15 +0200 Subject: [PATCH 16/22] PATCH 19 --- .../PgExternalAccountPersistenceManager.java | 218 +++++++++++------- .../util/email/MailJetApiClientWrapper.java | 167 +++++++++----- 2 files changed, 233 insertions(+), 152 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index f9e1dac0f8..0ad425cc1b 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -23,9 +23,13 @@ /** * This class is responsible for managing and persisting user data. */ + public class PgExternalAccountPersistenceManager implements IExternalAccountDataManager { private static final Logger log = LoggerFactory.getLogger(PgExternalAccountPersistenceManager.class); + // FIXED: Define constant instead of duplicating "unknown" 8 times + private static final String STAGE_UNKNOWN = "unknown"; + private final PostgresSqlDb database; /** @@ -43,34 +47,44 @@ public List getRecentlyChangedRecords() throws Segue // IMPORTANT: registered_contexts is JSONB[] (array of JSONB objects) in PostgreSQL // We use array_to_json() to convert it to proper JSON that Java can parse String query = "SELECT users.id, " - + " external_accounts.provider_user_identifier, " - + " users.email, " - + " users.role, " - + " users.given_name, " - + " users.deleted, " - + " users.email_verification_status, " - + " array_to_json(users.registered_contexts) AS registered_contexts, " // Convert JSONB[] to JSON - + " news_prefs.preference_value AS news_emails, " - + " events_prefs.preference_value AS events_emails, " - + " external_accounts.provider_last_updated " - + "FROM users " - + " LEFT OUTER JOIN user_preferences AS news_prefs " - + " ON users.id = news_prefs.user_id " - + " AND news_prefs.preference_type = 'EMAIL_PREFERENCE' " - + " AND news_prefs.preference_name = 'NEWS_AND_UPDATES' " - + " LEFT OUTER JOIN user_preferences AS events_prefs " - + " ON users.id = events_prefs.user_id " - + " AND events_prefs.preference_type = 'EMAIL_PREFERENCE' " - + " AND events_prefs.preference_name = 'EVENTS' " - + " LEFT OUTER JOIN external_accounts " - + " ON users.id = external_accounts.user_id " - + " AND external_accounts.provider_name = 'MailJet' " - + "WHERE (users.last_updated >= external_accounts.provider_last_updated " - + " OR news_prefs.last_updated >= external_accounts.provider_last_updated " - + " OR events_prefs.last_updated >= external_accounts.provider_last_updated " - + " OR external_accounts.provider_last_updated IS NULL) " - + "ORDER BY users.id"; + + " external_accounts.provider_user_identifier, " + + " users.email, " + + " users.role, " + + " users.given_name, " + + " users.deleted, " + + " users.email_verification_status, " + + " array_to_json(users.registered_contexts) AS registered_contexts, " // Convert JSONB[] to JSON + + " news_prefs.preference_value AS news_emails, " + + " events_prefs.preference_value AS events_emails, " + + " external_accounts.provider_last_updated " + + "FROM users " + + " LEFT OUTER JOIN user_preferences AS news_prefs " + + " ON users.id = news_prefs.user_id " + + " AND news_prefs.preference_type = 'EMAIL_PREFERENCE' " + + " AND news_prefs.preference_name = 'NEWS_AND_UPDATES' " + + " LEFT OUTER JOIN user_preferences AS events_prefs " + + " ON users.id = events_prefs.user_id " + + " AND events_prefs.preference_type = 'EMAIL_PREFERENCE' " + + " AND events_prefs.preference_name = 'EVENTS' " + + " LEFT OUTER JOIN external_accounts " + + " ON users.id = external_accounts.user_id " + + " AND external_accounts.provider_name = 'MailJet' " + + "WHERE (users.last_updated >= external_accounts.provider_last_updated " + + " OR news_prefs.last_updated >= external_accounts.provider_last_updated " + + " OR events_prefs.last_updated >= external_accounts.provider_last_updated " + + " OR external_accounts.provider_last_updated IS NULL) " + + "ORDER BY users.id"; + + // FIXED: Extract nested try block into separate method + return executeQueryAndBuildUserRecords(query); + } + /** + * Execute query and build user records list. + * Extracted to reduce nesting complexity. + */ + private List executeQueryAndBuildUserRecords(String query) + throws SegueDatabaseException { try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { @@ -84,10 +98,11 @@ public List getRecentlyChangedRecords() throws Segue UserExternalAccountChanges userChange = buildUserExternalAccountChanges(results); listOfResults.add(userChange); } catch (SQLException | JSONException e) { - // Log but continue processing other users + // FIXED: Added contextual information to log message long userId = results.getLong("id"); - log.error("MAILJET - Error building UserExternalAccountChanges for user ID: {}. Error: {}", - userId, e.getMessage(), e); + log.error("MAILJET - Error building UserExternalAccountChanges for user ID: {}. " + + "Error type: {}, Message: {}. Skipping this user and continuing with next.", + userId, e.getClass().getSimpleName(), e.getMessage(), e); } } @@ -96,8 +111,10 @@ public List getRecentlyChangedRecords() throws Segue } } catch (SQLException e) { - log.error("MAILJET - Database error while fetching recently changed records", e); - throw new SegueDatabaseException("Failed to retrieve recently changed user records", e); + // FIXED: Added contextual information to exception + String errorMsg = "Database error while fetching recently changed records"; + log.error("MAILJET - " + errorMsg, e); + throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -108,9 +125,9 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc } String query = "UPDATE external_accounts " - + "SET provider_last_updated = ? " - + "WHERE user_id = ? " - + "AND provider_name = 'MailJet'"; + + "SET provider_last_updated = ? " + + "WHERE user_id = ? " + + "AND provider_name = 'MailJet'"; try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) @@ -122,20 +139,22 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc if (rowsUpdated == 0) { log.warn("MAILJET - No rows updated when setting provider_last_updated for user ID: {}. " - + "User may not have an external_accounts record yet.", userId); + + "User may not have an external_accounts record yet.", userId); } else { log.debug("MAILJET - Updated provider_last_updated for user ID: {}", userId); } } catch (SQLException e) { - log.error("MAILJET - Database error updating provider_last_updated for user ID: {}", userId, e); - throw new SegueDatabaseException("Failed to update provider_last_updated for user: " + userId, e); + // FIXED: Added contextual information to exception + String errorMsg = String.format("Database error updating provider_last_updated for user ID: %d", userId); + log.error("MAILJET - " + errorMsg, e); + throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @Override public void updateExternalAccount(final Long userId, final String providerUserIdentifier) - throws SegueDatabaseException { + throws SegueDatabaseException { if (userId == null) { throw new IllegalArgumentException("User ID cannot be null"); @@ -143,9 +162,9 @@ public void updateExternalAccount(final Long userId, final String providerUserId // Upsert the value in, using Postgres 9.5+ syntax 'ON CONFLICT DO UPDATE ...' String query = "INSERT INTO external_accounts (user_id, provider_name, provider_user_identifier) " - + "VALUES (?, 'MailJet', ?) " - + "ON CONFLICT (user_id, provider_name) " - + "DO UPDATE SET provider_user_identifier = excluded.provider_user_identifier"; + + "VALUES (?, 'MailJet', ?) " + + "ON CONFLICT (user_id, provider_name) " + + "DO UPDATE SET provider_user_identifier = excluded.provider_user_identifier"; try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) @@ -157,16 +176,17 @@ public void updateExternalAccount(final Long userId, final String providerUserId if (rowsAffected > 0) { log.debug("MAILJET - Upserted external_account for user ID: {} with Mailjet ID: {}", - userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); + userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { log.warn("MAILJET - Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); } } catch (SQLException e) { - log.error("MAILJET - Database error upserting external_account for user ID: {} with Mailjet ID: {}", - userId, providerUserIdentifier, e); - throw new SegueDatabaseException( - "Failed to upsert external_account for user: " + userId, e); + // FIXED: Added contextual information to exception + String errorMsg = String.format("Database error upserting external_account for user ID: %d with Mailjet ID: %s", + userId, providerUserIdentifier); + log.error("MAILJET - " + errorMsg, e); + throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -176,7 +196,7 @@ public void updateExternalAccount(final Long userId, final String providerUserId * Parses boolean preference values with proper null handling. */ private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultSet results) - throws SQLException { + throws SQLException { Long userId = results.getLong("id"); @@ -189,16 +209,16 @@ private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultS Boolean eventsEmails = parseBooleanPreference(userId, "EVENTS", results, "events_emails"); return new UserExternalAccountChanges( - userId, - results.getString("provider_user_identifier"), - results.getString("email"), - Role.valueOf(results.getString("role")), - results.getString("given_name"), - results.getBoolean("deleted"), - EmailVerificationStatus.valueOf(results.getString("email_verification_status")), - newsEmails, - eventsEmails, - stage + userId, + results.getString("provider_user_identifier"), + results.getString("email"), + Role.valueOf(results.getString("role")), + results.getString("given_name"), + results.getBoolean("deleted"), + EmailVerificationStatus.valueOf(results.getString("email_verification_status")), + newsEmails, + eventsEmails, + stage ); } @@ -207,10 +227,10 @@ private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultS * PostgreSQL boolean columns can be NULL, which JDBC returns as false by default. * We need to check wasNull() to distinguish between false and NULL. * - * @param userId User ID for logging + * @param userId User ID for logging * @param preferenceName Name of preference for logging - * @param results ResultSet containing the data - * @param columnName Column name in ResultSet + * @param results ResultSet containing the data + * @param columnName Column name in ResultSet * @return Boolean value (true/false/null) */ private Boolean parseBooleanPreference(Long userId, String preferenceName, @@ -219,38 +239,48 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, boolean wasNull = results.wasNull(); if (wasNull) { - // User has no preference set - treat as null (not subscribed) - log.debug("MAILJET - User ID {} has NULL preference for {}. Treating as not subscribed.", - userId, preferenceName); + // FIXED: Conditional logging to improve performance + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} has NULL preference for {}. Treating as not subscribed.", + userId, preferenceName); + } + // FIXED: Return explicit null instead of implicit null return null; } - log.debug("MAILJET - User ID {} has preference {} = {}", userId, preferenceName, value); + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} has preference {} = {}", userId, preferenceName, value); + } return value; } /** * Extract stage information from registered_contexts JSONB[] field. * + *

* PostgreSQL JSONB[] is converted to JSON using array_to_json() in the query. * This gives us clean JSON like: [{"stage": "gcse", "examBoard": "aqa"}] * - * @param userId User ID for logging + * @param userId User ID for logging * @param registeredContextsJson JSONB[] converted to JSON via array_to_json() * @return stage string: "GCSE", "A Level", "GCSE and A Level", or "unknown" */ private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { - log.debug("MAILJET - User ID {} has NULL/empty registered_contexts. Stage: unknown", userId); - return "unknown"; + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} has NULL/empty registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); + } + return STAGE_UNKNOWN; } String trimmed = registeredContextsJson.trim(); // Check for empty JSON array if ("[]".equals(trimmed) || "null".equals(trimmed)) { - log.debug("MAILJET - User ID {} has empty/null registered_contexts. Stage: unknown", userId); - return "unknown"; + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} has empty/null registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); + } + return STAGE_UNKNOWN; } try { @@ -258,8 +288,11 @@ private String extractStageFromRegisteredContexts(Long userId, String registered JSONArray array = new JSONArray(trimmed); if (array.isEmpty()) { - log.debug("MAILJET - User ID {} has empty JSON array in registered_contexts. Stage: unknown", userId); - return "unknown"; + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} has empty JSON array in registered_contexts. Stage: {}", + userId, STAGE_UNKNOWN); + } + return STAGE_UNKNOWN; } // Search through array for 'stage' key @@ -270,8 +303,10 @@ private String extractStageFromRegisteredContexts(Long userId, String registered if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); - log.debug("MAILJET - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", - userId, stage, i, normalized); + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + userId, stage, i, normalized); + } return normalized; } } @@ -279,18 +314,23 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // No 'stage' key found, use fallback pattern matching String fallbackStage = fallbackStageDetection(trimmed); - if (!"unknown".equals(fallbackStage)) { - log.debug("MAILJET - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + if (!STAGE_UNKNOWN.equals(fallbackStage)) { + if (log.isDebugEnabled()) { + log.debug("MAILJET - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + } } else { - log.warn("MAILJET - User ID {} has registered_contexts but no 'stage' key found: {}. Stage: unknown", - userId, truncateForLog(trimmed)); + log.warn("MAILJET - User ID {} has registered_contexts but no 'stage' key found: {}. Stage: {}", + userId, truncateForLog(trimmed), STAGE_UNKNOWN); } return fallbackStage; } catch (JSONException e) { - log.warn("MAILJET - User ID {} has invalid JSON in registered_contexts: '{}'. Error: {}. Stage: unknown", - userId, truncateForLog(registeredContextsJson), e.getMessage()); - return "unknown"; + // FIXED: Added contextual information to log message + log.warn("MAILJET - User ID {} has invalid JSON in registered_contexts: '{}'. " + + "Error type: {}, Message: {}. Stage: {}", + userId, truncateForLog(registeredContextsJson), e.getClass().getSimpleName(), + e.getMessage(), STAGE_UNKNOWN); + return STAGE_UNKNOWN; } } @@ -301,17 +341,17 @@ private String extractStageFromRegisteredContexts(Long userId, String registered private String fallbackStageDetection(String jsonString) { String lower = jsonString.toLowerCase(); boolean hasGcse = lower.contains("gcse"); - boolean hasALevel = lower.contains("a_level") || lower.contains("alevel") || lower.contains("a level"); + boolean hasLevel = lower.contains("a_level") || lower.contains("alevel") || lower.contains("a level"); - if (hasGcse && hasALevel) { + if (hasGcse && hasLevel) { return "GCSE and A Level"; } else if (hasGcse) { return "GCSE"; - } else if (hasALevel) { + } else if (hasLevel) { return "A Level"; } - return "unknown"; + return STAGE_UNKNOWN; } /** @@ -319,7 +359,7 @@ private String fallbackStageDetection(String jsonString) { */ private String normalizeStage(String stage) { if (stage == null || stage.trim().isEmpty()) { - return "unknown"; + return STAGE_UNKNOWN; } String normalized = stage.trim().toLowerCase(); @@ -342,9 +382,9 @@ private String normalizeStage(String stage) { return "ALL"; default: // Warn about unexpected stage values - log.warn("MAILJET - Unexpected stage value '{}' encountered. Returning 'unknown'. " - + "Expected values: gcse, a_level, gcse_and_a_level, both", stage); - return "unknown"; + log.warn("MAILJET - Unexpected stage value '{}' encountered. Returning '{}'. " + + "Expected values: gcse, a_level, gcse_and_a_level, both", stage, STAGE_UNKNOWN); + return STAGE_UNKNOWN; } } diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index c421fb9b59..af5863b296 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -39,6 +39,8 @@ public class MailJetApiClientWrapper { private static final Logger log = LoggerFactory.getLogger(MailJetApiClientWrapper.class); + private static final String PROPERTY_VALUE_KEY = "value"; + private final MailjetClient mailjetClient; private final String newsListId; private final String eventsListId; @@ -109,11 +111,12 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma } if (isCommunicationException(e)) { - log.error("Communication error fetching Mailjet account", e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + String errorMsg = String.format("Communication error fetching Mailjet account: %s", mailjetIdOrEmail); + log.error(errorMsg, e); + throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error fetching Mailjet account", e); + log.error("Error fetching Mailjet account: {}. Error: {}", mailjetIdOrEmail, e.getMessage(), e); throw e; } } @@ -149,11 +152,12 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE } if (isCommunicationException(e)) { - log.error("Communication error deleting Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + String errorMsg = String.format("Communication error deleting Mailjet account: %s", mailjetId); + log.error(errorMsg, e); + throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error deleting Mailjet account: {}", mailjetId, e); + log.error("Error deleting Mailjet account: {}. Error: {}", mailjetId, e.getMessage(), e); throw e; } } @@ -176,57 +180,88 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce String normalizedEmail = email.trim().toLowerCase(); try { - MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); - MailjetResponse response = mailjetClient.post(request); - - if (response.getStatus() == 201 || response.getStatus() == 200) { - JSONObject responseData = response.getData().getJSONObject(0); - String mailjetId = String.valueOf(responseData.get("ID")); - log.info("Successfully created Mailjet account: {}", mailjetId); - return mailjetId; - } - - log.error("Unexpected response status {} when creating Mailjet account", response.getStatus()); - throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + return createNewMailjetAccount(normalizedEmail); } catch (MailjetClientRequestException e) { - if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { - log.debug("User already exists in Mailjet, fetching existing account"); - - try { - JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); - if (existingAccount != null) { - String mailjetId = String.valueOf(existingAccount.get("ID")); - log.info("Retrieved existing Mailjet account: {}", mailjetId); - return mailjetId; - } else { - log.error("User reported as existing but couldn't fetch account"); - throw new MailjetException("Account exists but couldn't be retrieved"); - } - } catch (JSONException je) { - log.error("JSON parsing error when retrieving existing account", je); - throw new MailjetException("Failed to parse existing account data", je); - } - } else { - log.error("Failed to create Mailjet account: {}", e.getMessage(), e); - throw new MailjetException("Failed to create account: " + e.getMessage(), e); - } + return handleUserAlreadyExists(e, normalizedEmail); } catch (MailjetException e) { - if (isCommunicationException(e)) { - log.error("Communication error creating Mailjet account", e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); - } - - log.error("Error creating Mailjet account", e); - throw e; + return handleMailjetException(e, normalizedEmail); } catch (JSONException e) { - log.error("JSON parsing error when creating account", e); - throw new MailjetException("Failed to parse Mailjet response", e); + String errorMsg = String.format("JSON parsing error when creating account for email: %s", normalizedEmail); + log.error(errorMsg, e); + throw new MailjetException(errorMsg, e); } } + /** + * Create a new Mailjet account for the given email. + * Extracted to reduce cognitive complexity. + */ + private String createNewMailjetAccount(String normalizedEmail) throws MailjetException, JSONException { + MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); + MailjetResponse response = mailjetClient.post(request); + + if (response.getStatus() == 201 || response.getStatus() == 200) { + JSONObject responseData = response.getData().getJSONObject(0); + String mailjetId = String.valueOf(responseData.get("ID")); + log.info("Successfully created Mailjet account: {}", mailjetId); + return mailjetId; + } + + log.error("Unexpected response status {} when creating Mailjet account", response.getStatus()); + throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + } + + /** + * Handle the case where user already exists in Mailjet. + * Extracted to reduce cognitive complexity. + */ + private String handleUserAlreadyExists(MailjetClientRequestException e, String normalizedEmail) + throws MailjetException { + if (e.getMessage() != null && e.getMessage().toLowerCase().contains("already exists")) { + log.debug("User already exists in Mailjet, fetching existing account"); + + try { + JSONObject existingAccount = getAccountByIdOrEmail(normalizedEmail); + if (existingAccount != null) { + String mailjetId = String.valueOf(existingAccount.get("ID")); + log.info("Retrieved existing Mailjet account: {}", mailjetId); + return mailjetId; + } else { + String errorMsg = String.format("User reported as existing but couldn't fetch account: %s", normalizedEmail); + log.error(errorMsg); + throw new MailjetException(errorMsg); + } + } catch (JSONException je) { + String errorMsg = String.format("JSON parsing error when retrieving existing account: %s", normalizedEmail); + log.error(errorMsg, je); + throw new MailjetException(errorMsg, je); + } + } else { + String errorMsg = String.format("Failed to create Mailjet account for email: %s. Error: %s", + normalizedEmail, e.getMessage()); + log.error(errorMsg, e); + throw new MailjetException(errorMsg, e); + } + } + + /** + * Handle general Mailjet exceptions. + * Extracted to reduce cognitive complexity. + */ + private String handleMailjetException(MailjetException e, String normalizedEmail) throws MailjetException { + if (isCommunicationException(e)) { + String errorMsg = String.format("Communication error creating Mailjet account for email: %s", normalizedEmail); + log.error(errorMsg, e); + throw new MailjetClientCommunicationException(errorMsg, e); + } + + log.error("Error creating Mailjet account for email: {}. Error: {}", normalizedEmail, e.getMessage(), e); + throw e; + } + /** * Update user details for an existing MailJet account. * @@ -246,11 +281,11 @@ public void updateUserProperties(final String mailjetId, final String firstName, try { MailjetRequest request = new MailjetRequest(Contactdata.resource, mailjetId).property(Contactdata.DATA, new JSONArray().put( - new JSONObject().put("Name", "firstname").put("value", firstName != null ? firstName : "")) - .put(new JSONObject().put("Name", "role").put("value", role != null ? role : "")).put( + new JSONObject().put("Name", "firstname").put(PROPERTY_VALUE_KEY, firstName != null ? firstName : "")) + .put(new JSONObject().put("Name", "role").put(PROPERTY_VALUE_KEY, role != null ? role : "")).put( new JSONObject().put("Name", "verification_status") - .put("value", emailVerificationStatus != null ? emailVerificationStatus : "")) - .put(new JSONObject().put("Name", "stage").put("value", stage != null ? stage : "unknown"))); + .put(PROPERTY_VALUE_KEY, emailVerificationStatus != null ? emailVerificationStatus : "")) + .put(new JSONObject().put("Name", "stage").put(PROPERTY_VALUE_KEY, stage != null ? stage : "unknown"))); MailjetResponse response = mailjetClient.put(request); @@ -266,16 +301,19 @@ public void updateUserProperties(final String mailjetId, final String firstName, } catch (MailjetException e) { if (isNotFoundException(e)) { - log.error("Mailjet contact not found when updating properties: {}. Contact may have been deleted", mailjetId); - throw new MailjetException("Contact not found (404) when updating properties: " + mailjetId, e); + String errorMsg = String.format("Mailjet contact not found when updating properties: %s. " + + "Contact may have been deleted", mailjetId); + log.error(errorMsg); + throw new MailjetException(errorMsg, e); } if (isCommunicationException(e)) { - log.error("Communication error updating properties for Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + String errorMsg = String.format("Communication error updating properties for Mailjet account: %s", mailjetId); + log.error(errorMsg, e); + throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error updating properties for Mailjet account: {}", mailjetId, e); + log.error("Error updating properties for Mailjet account: {}. Error: {}", mailjetId, e.getMessage(), e); throw e; } } @@ -323,17 +361,20 @@ ContactManagecontactslists.CONTACTSLISTS, new JSONArray().put( } catch (MailjetException e) { if (isNotFoundException(e)) { - log.error("Mailjet contact not found when updating subscriptions: {}. Contact may have been deleted", - mailjetId); - throw new MailjetException("Contact not found (404) when updating subscriptions: " + mailjetId, e); + String errorMsg = String.format("Mailjet contact not found when updating subscriptions: %s. " + + "Contact may have been deleted", mailjetId); + log.error(errorMsg); + throw new MailjetException(errorMsg, e); } if (isCommunicationException(e)) { - log.error("Communication error updating subscriptions for Mailjet account: {}", mailjetId, e); - throw new MailjetClientCommunicationException("Failed to communicate with Mailjet", e); + String errorMsg = String.format("Communication error updating subscriptions for Mailjet account: %s", + mailjetId); + log.error(errorMsg, e); + throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error updating subscriptions for Mailjet account: {}", mailjetId, e); + log.error("Error updating subscriptions for Mailjet account: {}. Error: {}", mailjetId, e.getMessage(), e); throw e; } } From 5309df8c9392b7c3b3884846a5d857d8931a1be5 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 13:42:48 +0200 Subject: [PATCH 17/22] PATCH 19 --- .../PgExternalAccountPersistenceManager.java | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index 0ad425cc1b..f6ea2d35b4 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -298,8 +298,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered // Search through array for 'stage' key for (int i = 0; i < array.length(); i++) { Object item = array.get(i); - if (item instanceof JSONObject) { - JSONObject obj = (JSONObject) item; + if (item instanceof JSONObject obj) { if (obj.has("stage")) { String stage = obj.getString("stage"); String normalized = normalizeStage(stage); @@ -364,28 +363,18 @@ private String normalizeStage(String stage) { String normalized = stage.trim().toLowerCase(); - switch (normalized) { - case "gcse": - return "GCSE"; - case "a_level": - case "a level": - case "alevel": - case "a-level": - return "A Level"; - case "gcse_and_a_level": - case "gcse and a level": - case "both": - case "gcse,a_level": - case "gcse, a level": - return "GCSE and A Level"; - case "all": - return "ALL"; - default: + return switch (normalized) { + case "gcse" -> "GCSE"; + case "a_level", "a level", "alevel", "a-level" -> "A Level"; + case "gcse_and_a_level", "gcse and a level", "both", "gcse,a_level", "gcse, a level" -> "GCSE and A Level"; + case "all" -> "ALL"; + default -> { // Warn about unexpected stage values log.warn("MAILJET - Unexpected stage value '{}' encountered. Returning '{}'. " + "Expected values: gcse, a_level, gcse_and_a_level, both", stage, STAGE_UNKNOWN); - return STAGE_UNKNOWN; - } + yield STAGE_UNKNOWN; + } + }; } /** From a86135e1ae85b95e81ce54336e98f29638a04fb0 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 14:03:11 +0200 Subject: [PATCH 18/22] PATCH 19 --- .../api/managers/ExternalAccountManager.java | 5 +- .../PgExternalAccountPersistenceManager.java | 78 ++++++++----------- 2 files changed, 36 insertions(+), 47 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index 66dbec9c50..c0bc409597 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -66,7 +66,8 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); } catch (SegueDatabaseException e) { log.error("Database error whilst collecting users whose details have changed", e); - throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e); + throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + + e.getMessage()); } if (userRecordsToUpdate.isEmpty()) { @@ -91,7 +92,7 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro } catch (MailjetClientCommunicationException e) { metrics.incrementCommunicationError(); log.error("Failed to communicate with Mailjet while processing user ID: {}", userId, e); - throw new ExternalAccountSynchronisationException("Failed to connect to Mailjet" + e); + throw new ExternalAccountSynchronisationException("Failed to connect to Mailjet: " + e.getMessage()); } catch (MailjetRateLimitException e) { metrics.incrementRateLimitError(); diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index f6ea2d35b4..e86d300dc8 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -27,8 +27,8 @@ public class PgExternalAccountPersistenceManager implements IExternalAccountDataManager { private static final Logger log = LoggerFactory.getLogger(PgExternalAccountPersistenceManager.class); - // FIXED: Define constant instead of duplicating "unknown" 8 times private static final String STAGE_UNKNOWN = "unknown"; + private static final String MAILJET = "MAILJET - "; private final PostgresSqlDb database; @@ -75,7 +75,6 @@ public List getRecentlyChangedRecords() throws Segue + " OR external_accounts.provider_last_updated IS NULL) " + "ORDER BY users.id"; - // FIXED: Extract nested try block into separate method return executeQueryAndBuildUserRecords(query); } @@ -88,36 +87,38 @@ private List executeQueryAndBuildUserRecords(String try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { - log.debug("MAILJET - Executing query to fetch recently changed user records"); + log.debug(MAILJET + "Executing query to fetch recently changed user records"); try (ResultSet results = pst.executeQuery()) { List listOfResults = new ArrayList<>(); while (results.next()) { - try { - UserExternalAccountChanges userChange = buildUserExternalAccountChanges(results); - listOfResults.add(userChange); - } catch (SQLException | JSONException e) { - // FIXED: Added contextual information to log message - long userId = results.getLong("id"); - log.error("MAILJET - Error building UserExternalAccountChanges for user ID: {}. " - + "Error type: {}, Message: {}. Skipping this user and continuing with next.", - userId, e.getClass().getSimpleName(), e.getMessage(), e); - } + extracted(results, listOfResults); } - log.debug("MAILJET - Retrieved {} user records requiring synchronization", listOfResults.size()); + log.debug(MAILJET + "Retrieved {} user records requiring synchronization", listOfResults.size()); return listOfResults; } } catch (SQLException e) { - // FIXED: Added contextual information to exception String errorMsg = "Database error while fetching recently changed records"; - log.error("MAILJET - " + errorMsg, e); + log.error(MAILJET + "{}", errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } + private void extracted(ResultSet results, List listOfResults) throws SQLException { + try { + UserExternalAccountChanges userChange = buildUserExternalAccountChanges(results); + listOfResults.add(userChange); + } catch (SQLException | JSONException e) { + long userId = results.getLong("id"); + log.error(MAILJET + "Error building UserExternalAccountChanges for user ID: {}. " + + "Error type: {}, Message: {}. Skipping this user and continuing with next.", + userId, e.getClass().getSimpleName(), e.getMessage(), e); + } + } + @Override public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseException { if (userId == null) { @@ -138,16 +139,15 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc int rowsUpdated = pst.executeUpdate(); if (rowsUpdated == 0) { - log.warn("MAILJET - No rows updated when setting provider_last_updated for user ID: {}. " + log.warn(MAILJET + "No rows updated when setting provider_last_updated for user ID: {}. " + "User may not have an external_accounts record yet.", userId); } else { - log.debug("MAILJET - Updated provider_last_updated for user ID: {}", userId); + log.debug(MAILJET + "Updated provider_last_updated for user ID: {}", userId); } } catch (SQLException e) { - // FIXED: Added contextual information to exception String errorMsg = String.format("Database error updating provider_last_updated for user ID: %d", userId); - log.error("MAILJET - " + errorMsg, e); + log.error(MAILJET + "{}", errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -160,7 +160,6 @@ public void updateExternalAccount(final Long userId, final String providerUserId throw new IllegalArgumentException("User ID cannot be null"); } - // Upsert the value in, using Postgres 9.5+ syntax 'ON CONFLICT DO UPDATE ...' String query = "INSERT INTO external_accounts (user_id, provider_name, provider_user_identifier) " + "VALUES (?, 'MailJet', ?) " + "ON CONFLICT (user_id, provider_name) " @@ -175,17 +174,16 @@ public void updateExternalAccount(final Long userId, final String providerUserId int rowsAffected = pst.executeUpdate(); if (rowsAffected > 0) { - log.debug("MAILJET - Upserted external_account for user ID: {} with Mailjet ID: {}", + log.debug(MAILJET + "Upserted external_account for user ID: {} with Mailjet ID: {}", userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { - log.warn("MAILJET - Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); + log.warn(MAILJET + "Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); } } catch (SQLException e) { - // FIXED: Added contextual information to exception String errorMsg = String.format("Database error upserting external_account for user ID: %d with Mailjet ID: %s", userId, providerUserIdentifier); - log.error("MAILJET - " + errorMsg, e); + log.error(MAILJET + "{}", errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -200,11 +198,9 @@ private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultS Long userId = results.getLong("id"); - // Parse registered_contexts (JSONB[] -> String -> stage) String registeredContextsJson = results.getString("registered_contexts"); String stage = extractStageFromRegisteredContexts(userId, registeredContextsJson); - // Parse boolean preferences with null handling Boolean newsEmails = parseBooleanPreference(userId, "NEWS_AND_UPDATES", results, "news_emails"); Boolean eventsEmails = parseBooleanPreference(userId, "EVENTS", results, "events_emails"); @@ -239,17 +235,15 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, boolean wasNull = results.wasNull(); if (wasNull) { - // FIXED: Conditional logging to improve performance if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} has NULL preference for {}. Treating as not subscribed.", + log.debug(MAILJET + "User ID {} has NULL preference for {}. Treating as not subscribed.", userId, preferenceName); } - // FIXED: Return explicit null instead of implicit null return null; } if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} has preference {} = {}", userId, preferenceName, value); + log.debug(MAILJET + "User ID {} has preference {} = {}", userId, preferenceName, value); } return value; } @@ -268,34 +262,31 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} has NULL/empty registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); + log.debug(MAILJET + "User ID {} has NULL/empty registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); } return STAGE_UNKNOWN; } String trimmed = registeredContextsJson.trim(); - // Check for empty JSON array if ("[]".equals(trimmed) || "null".equals(trimmed)) { if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} has empty/null registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); + log.debug(MAILJET + "User ID {} has empty/null registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); } return STAGE_UNKNOWN; } try { - // Parse as JSONArray (array_to_json returns proper JSON array) JSONArray array = new JSONArray(trimmed); if (array.isEmpty()) { if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} has empty JSON array in registered_contexts. Stage: {}", + log.debug(MAILJET + "User ID {} has empty JSON array in registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); } return STAGE_UNKNOWN; } - // Search through array for 'stage' key for (int i = 0; i < array.length(); i++) { Object item = array.get(i); if (item instanceof JSONObject obj) { @@ -303,7 +294,7 @@ private String extractStageFromRegisteredContexts(Long userId, String registered String stage = obj.getString("stage"); String normalized = normalizeStage(stage); if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + log.debug(MAILJET + "User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", userId, stage, i, normalized); } return normalized; @@ -311,21 +302,19 @@ private String extractStageFromRegisteredContexts(Long userId, String registered } } - // No 'stage' key found, use fallback pattern matching String fallbackStage = fallbackStageDetection(trimmed); if (!STAGE_UNKNOWN.equals(fallbackStage)) { if (log.isDebugEnabled()) { - log.debug("MAILJET - User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); + log.debug(MAILJET + "User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); } } else { - log.warn("MAILJET - User ID {} has registered_contexts but no 'stage' key found: {}. Stage: {}", + log.warn(MAILJET + "User ID {} has registered_contexts but no 'stage' key found: {}. Stage: {}", userId, truncateForLog(trimmed), STAGE_UNKNOWN); } return fallbackStage; } catch (JSONException e) { - // FIXED: Added contextual information to log message - log.warn("MAILJET - User ID {} has invalid JSON in registered_contexts: '{}'. " + log.warn(MAILJET + "User ID {} has invalid JSON in registered_contexts: '{}'. " + "Error type: {}, Message: {}. Stage: {}", userId, truncateForLog(registeredContextsJson), e.getClass().getSimpleName(), e.getMessage(), STAGE_UNKNOWN); @@ -369,8 +358,7 @@ private String normalizeStage(String stage) { case "gcse_and_a_level", "gcse and a level", "both", "gcse,a_level", "gcse, a level" -> "GCSE and A Level"; case "all" -> "ALL"; default -> { - // Warn about unexpected stage values - log.warn("MAILJET - Unexpected stage value '{}' encountered. Returning '{}'. " + log.warn(MAILJET + "Unexpected stage value '{}' encountered. Returning '{}'. " + "Expected values: gcse, a_level, gcse_and_a_level, both", stage, STAGE_UNKNOWN); yield STAGE_UNKNOWN; } From 586ea07f8154a0ac37440d388cf96639a564a415 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 15:16:17 +0200 Subject: [PATCH 19/22] PATCH 19 --- .../util/email/MailJetApiClientWrapper.java | 57 +- .../managers/ExternalAccountManagerTest.java | 50 +- ...ExternalAccountPersistenceManagerTest.java | 980 +++++++++--------- 3 files changed, 570 insertions(+), 517 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java index af5863b296..50daa7cf06 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java +++ b/src/main/java/uk/ac/cam/cl/dtg/util/email/MailJetApiClientWrapper.java @@ -112,11 +112,9 @@ public JSONObject getAccountByIdOrEmail(final String mailjetIdOrEmail) throws Ma if (isCommunicationException(e)) { String errorMsg = String.format("Communication error fetching Mailjet account: %s", mailjetIdOrEmail); - log.error(errorMsg, e); throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error fetching Mailjet account: {}. Error: {}", mailjetIdOrEmail, e.getMessage(), e); throw e; } } @@ -141,7 +139,6 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE } else if (response.getStatus() == 404) { log.debug("Attempted to delete non-existent Mailjet account: {}", mailjetId); } else { - log.error("Unexpected response status {} when deleting Mailjet account", response.getStatus()); throw new MailjetException("Failed to delete account. Status: " + response.getStatus()); } @@ -153,11 +150,9 @@ public void permanentlyDeleteAccountById(final String mailjetId) throws MailjetE if (isCommunicationException(e)) { String errorMsg = String.format("Communication error deleting Mailjet account: %s", mailjetId); - log.error(errorMsg, e); throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error deleting Mailjet account: {}. Error: {}", mailjetId, e.getMessage(), e); throw e; } } @@ -190,7 +185,6 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce } catch (JSONException e) { String errorMsg = String.format("JSON parsing error when creating account for email: %s", normalizedEmail); - log.error(errorMsg, e); throw new MailjetException(errorMsg, e); } } @@ -200,18 +194,23 @@ public String addNewUserOrGetUserIfExists(final String email) throws MailjetExce * Extracted to reduce cognitive complexity. */ private String createNewMailjetAccount(String normalizedEmail) throws MailjetException, JSONException { - MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); - MailjetResponse response = mailjetClient.post(request); - - if (response.getStatus() == 201 || response.getStatus() == 200) { - JSONObject responseData = response.getData().getJSONObject(0); - String mailjetId = String.valueOf(responseData.get("ID")); - log.info("Successfully created Mailjet account: {}", mailjetId); - return mailjetId; - } + try { + MailjetRequest request = new MailjetRequest(Contact.resource).property(Contact.EMAIL, normalizedEmail); + MailjetResponse response = mailjetClient.post(request); + + if (response.getStatus() == 201 || response.getStatus() == 200) { + JSONObject responseData = response.getData().getJSONObject(0); + String mailjetId = String.valueOf(responseData.get("ID")); + log.info("Successfully created Mailjet account: {}", mailjetId); + return mailjetId; + } + + throw new MailjetException("Failed to create account. Status: " + response.getStatus()); - log.error("Unexpected response status {} when creating Mailjet account", response.getStatus()); - throw new MailjetException("Failed to create account. Status: " + response.getStatus()); + } catch (JSONException e) { + String errorMsg = String.format("JSON parsing error when creating account for email: %s", normalizedEmail); + throw new MailjetException(errorMsg, e); + } } /** @@ -231,18 +230,15 @@ private String handleUserAlreadyExists(MailjetClientRequestException e, String n return mailjetId; } else { String errorMsg = String.format("User reported as existing but couldn't fetch account: %s", normalizedEmail); - log.error(errorMsg); throw new MailjetException(errorMsg); } } catch (JSONException je) { String errorMsg = String.format("JSON parsing error when retrieving existing account: %s", normalizedEmail); - log.error(errorMsg, je); throw new MailjetException(errorMsg, je); } } else { String errorMsg = String.format("Failed to create Mailjet account for email: %s. Error: %s", normalizedEmail, e.getMessage()); - log.error(errorMsg, e); throw new MailjetException(errorMsg, e); } } @@ -254,11 +250,9 @@ private String handleUserAlreadyExists(MailjetClientRequestException e, String n private String handleMailjetException(MailjetException e, String normalizedEmail) throws MailjetException { if (isCommunicationException(e)) { String errorMsg = String.format("Communication error creating Mailjet account for email: %s", normalizedEmail); - log.error(errorMsg, e); throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error creating Mailjet account for email: {}. Error: {}", normalizedEmail, e.getMessage(), e); throw e; } @@ -292,28 +286,27 @@ public void updateUserProperties(final String mailjetId, final String firstName, if (response.getStatus() == 200 && response.getTotal() == 1) { log.debug("Successfully updated properties for Mailjet account: {}", mailjetId); } else { - log.error("Failed to update properties for Mailjet account: {}. Status: {}, Total: {}", mailjetId, - response.getStatus(), response.getTotal()); throw new MailjetException( String.format("Failed to update user properties. Status: %d, Total: %d", response.getStatus(), response.getTotal())); } + } catch (JSONException e) { + String errorMsg = String.format("JSON parsing error when updating properties for Mailjet account: %s", mailjetId); + throw new MailjetException(errorMsg, e); + } catch (MailjetException e) { if (isNotFoundException(e)) { String errorMsg = String.format("Mailjet contact not found when updating properties: %s. " + "Contact may have been deleted", mailjetId); - log.error(errorMsg); throw new MailjetException(errorMsg, e); } if (isCommunicationException(e)) { String errorMsg = String.format("Communication error updating properties for Mailjet account: %s", mailjetId); - log.error(errorMsg, e); throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error updating properties for Mailjet account: {}. Error: {}", mailjetId, e.getMessage(), e); throw e; } } @@ -352,29 +345,29 @@ ContactManagecontactslists.CONTACTSLISTS, new JSONArray().put( if (response.getStatus() == 201 && response.getTotal() == 1) { log.debug("Successfully updated subscriptions for Mailjet account: {}", mailjetId); } else { - log.error("Failed to update subscriptions for Mailjet account: {}. Status: {}, Total: {}", mailjetId, - response.getStatus(), response.getTotal()); throw new MailjetException( String.format("Failed to update user subscriptions. Status: %d, Total: %d", response.getStatus(), response.getTotal())); } + } catch (JSONException e) { + String errorMsg = + String.format("JSON parsing error when updating subscriptions for Mailjet account: %s", mailjetId); + throw new MailjetException(errorMsg, e); + } catch (MailjetException e) { if (isNotFoundException(e)) { String errorMsg = String.format("Mailjet contact not found when updating subscriptions: %s. " + "Contact may have been deleted", mailjetId); - log.error(errorMsg); throw new MailjetException(errorMsg, e); } if (isCommunicationException(e)) { String errorMsg = String.format("Communication error updating subscriptions for Mailjet account: %s", mailjetId); - log.error(errorMsg, e); throw new MailjetClientCommunicationException(errorMsg, e); } - log.error("Error updating subscriptions for Mailjet account: {}. Error: {}", mailjetId, e.getMessage(), e); throw e; } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java index 9897fb8e85..dfea076e26 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java @@ -6,15 +6,20 @@ import static org.easymock.EasyMock.replay; import static org.easymock.EasyMock.verify; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.mailjet.client.errors.MailjetClientCommunicationException; import com.mailjet.client.errors.MailjetException; import com.mailjet.client.errors.MailjetRateLimitException; +import java.util.ArrayList; import java.util.List; import org.json.JSONObject; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; import uk.ac.cam.cl.dtg.isaac.dos.users.EmailVerificationStatus; import uk.ac.cam.cl.dtg.isaac.dos.users.Role; import uk.ac.cam.cl.dtg.isaac.dos.users.UserExternalAccountChanges; @@ -22,13 +27,6 @@ import uk.ac.cam.cl.dtg.segue.dao.users.IExternalAccountDataManager; import uk.ac.cam.cl.dtg.util.email.MailJetApiClientWrapper; import uk.ac.cam.cl.dtg.util.email.MailJetSubscriptionAction; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; - -import java.util.ArrayList; - -import static org.junit.jupiter.api.Assertions.*; class ExternalAccountManagerTest { @@ -497,7 +495,7 @@ void synchroniseChangedUsers_WithUnexpectedError_ShouldLogAndContinue() @Test void synchroniseChangedUsers_WithNewUserAndNullMailjetId_ShouldThrow() - throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { + throws SegueDatabaseException, MailjetException { // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, null, "test@example.com", Role.STUDENT, "John", false, @@ -511,7 +509,36 @@ void synchroniseChangedUsers_WithNewUserAndNullMailjetId_ShouldThrow() replay(mockDatabase, mockMailjetApi); // Act & Assert - externalAccountManager.synchroniseChangedUsers(); + assertThrows(ExternalAccountSynchronisationException.class, + () -> externalAccountManager.synchroniseChangedUsers()); + + verify(mockDatabase, mockMailjetApi); + } + + @Test + void synchroniseChangedUsers_WithEmailChangeAndNullNewId_ShouldThrow() + throws SegueDatabaseException, MailjetException { + // Arrange + UserExternalAccountChanges userChanges = new UserExternalAccountChanges( + 1L, "existingId", "newemail@example.com", Role.STUDENT, "John", false, + EmailVerificationStatus.VERIFIED, true, false, "GCSE" + ); + List changedUsers = List.of(userChanges); + + JSONObject oldDetails = new JSONObject(); + oldDetails.put("Email", "oldemail@example.com"); + + expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); + expect(mockMailjetApi.getAccountByIdOrEmail("existingId")).andReturn(oldDetails); + mockMailjetApi.permanentlyDeleteAccountById("existingId"); + expectLastCall(); + expect(mockMailjetApi.addNewUserOrGetUserIfExists("newemail@example.com")).andReturn(null); + + replay(mockDatabase, mockMailjetApi); + + // Act & Assert + assertThrows(ExternalAccountSynchronisationException.class, + () -> externalAccountManager.synchroniseChangedUsers()); verify(mockDatabase, mockMailjetApi); } @@ -527,8 +554,6 @@ void synchroniseChangedUsers_WithNewUserAndDeliveryFailed_ShouldSkip() List changedUsers = List.of(userChanges); expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - mockDatabase.updateProviderLastUpdated(1L); - expectLastCall(); mockDatabase.updateExternalAccount(1L, null); expectLastCall(); @@ -552,9 +577,6 @@ void synchroniseChangedUsers_WithNewUserAndDeleted_ShouldSkip() List changedUsers = List.of(userChanges); expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - - mockDatabase.updateProviderLastUpdated(1L); - expectLastCall(); mockDatabase.updateExternalAccount(1L, null); expectLastCall(); diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java index 6ce73c1a11..99605f2a4d 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java @@ -26,6 +26,8 @@ import static org.easymock.EasyMock.*; import static org.junit.jupiter.api.Assertions.*; +import java.sql.*; + class PgExternalAccountPersistenceManagerTest { private PgExternalAccountPersistenceManager persistenceManager; @@ -122,484 +124,520 @@ void getRecentlyChangedRecords_WithDatabaseError_ShouldThrowException() throws E verify(mockDatabase); } - @Nested - class UpdateProviderLastUpdatedTests { - - @Test - void updateProviderLastUpdated_WithValidUserId_ShouldUpdateTimestamp() throws Exception { - // Arrange - Long userId = 123L; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); - expectLastCall(); - mockPreparedStatement.setLong(2, userId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateProviderLastUpdated(userId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateProviderLastUpdated_WithNonExistentUser_ShouldLogWarning() throws Exception { - // Arrange - Long userId = 999L; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); - expectLastCall(); - mockPreparedStatement.setLong(2, userId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(0); // No rows updated - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - Should not throw, just log warning - persistenceManager.updateProviderLastUpdated(userId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateProviderLastUpdated_WithNullUserId_ShouldThrowException() { - // Act & Assert - assertThrows(IllegalArgumentException.class, - () -> persistenceManager.updateProviderLastUpdated(null)); - } - - @Test - void updateProviderLastUpdated_WithDatabaseError_ShouldThrowException() throws Exception { - // Arrange - Long userId = 123L; - expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); - - replay(mockDatabase); - - // Act & Assert - assertThrows(SegueDatabaseException.class, - () -> persistenceManager.updateProviderLastUpdated(userId)); - - verify(mockDatabase); - } + @Test + void getRecentlyChangedRecords_WithInvalidUserData_ShouldSkipAndContinue() throws Exception { + // Arrange + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + expect(mockPreparedStatement.executeQuery()).andReturn(mockResultSet); + + // First user has invalid data (SQLException) + expect(mockResultSet.next()).andReturn(true).once(); + expect(mockResultSet.getLong("id")).andThrow(new SQLException("Invalid data")); + + // Second user is valid + expect(mockResultSet.next()).andReturn(true).once(); + setupMockResultSetForUser(2L, "mailjetId456", "valid@example.com", "TEACHER", + "Jane", false, "VERIFIED", "[{\"stage\": \"a_level\"}]", false, true, false, false); + + expect(mockResultSet.next()).andReturn(false).once(); + + mockResultSet.close(); + expectLastCall(); + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + + // Act + List result = persistenceManager.getRecentlyChangedRecords(); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + assertEquals(1, result.size()); + assertEquals(2L, result.get(0).getUserId()); } + } + + @Nested + class UpdateProviderLastUpdatedTests { + + @Test + void updateProviderLastUpdated_WithValidUserId_ShouldUpdateTimestamp() throws Exception { + // Arrange + Long userId = 123L; - @Nested - class UpdateExternalAccountTests { - - @Test - void updateExternalAccount_WithNewAccount_ShouldInsert() throws Exception { - // Arrange - Long userId = 123L; - String mailjetId = "mailjet456"; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, mailjetId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateExternalAccount(userId, mailjetId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithExistingAccount_ShouldUpdate() throws Exception { - // Arrange - Long userId = 123L; - String mailjetId = "newMailjetId"; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, mailjetId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateExternalAccount(userId, mailjetId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithNullMailjetId_ShouldClearAccount() throws Exception { - // Arrange - Long userId = 123L; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, null); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateExternalAccount(userId, null); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithNullUserId_ShouldThrowException() { - // Act & Assert - assertThrows(IllegalArgumentException.class, - () -> persistenceManager.updateExternalAccount(null, "mailjetId")); - } - - @Test - void updateExternalAccount_WithZeroRowsAffected_ShouldLogWarning() throws Exception { - // Arrange - Long userId = 123L; - String mailjetId = "mailjet456"; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, mailjetId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(0); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - Should not throw, just log warning - persistenceManager.updateExternalAccount(userId, mailjetId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithDatabaseError_ShouldThrowException() throws Exception { - // Arrange - Long userId = 123L; - expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); - - replay(mockDatabase); - - // Act & Assert - assertThrows(SegueDatabaseException.class, - () -> persistenceManager.updateExternalAccount(userId, "mailjetId")); - - verify(mockDatabase); - } + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); + expectLastCall(); + mockPreparedStatement.setLong(2, userId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateProviderLastUpdated(userId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); } - @Nested - class StageExtractionTests { - - @ParameterizedTest - @CsvSource({ - "'[{\"stage\": \"gcse\"}]', GCSE", - "'[{\"stage\": \"a_level\"}]', A Level", - "'[{\"stage\": \"a level\"}]', A Level", - "'[{\"stage\": \"alevel\"}]', A Level", - "'[{\"stage\": \"gcse_and_a_level\"}]', GCSE and A Level", - "'[{\"stage\": \"both\"}]', GCSE and A Level", - "'[{\"stage\": \"all\"}]', ALL" - }) - void extractStage_WithValidStageValues_ShouldNormalizeCorrectly(String json, String expected) throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(json); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals(expected, result.getStage()); - } - - @ParameterizedTest - @NullAndEmptySource - @ValueSource(strings = {"[]", "null", " "}) - void extractStage_WithEmptyOrNullContext_ShouldReturnUnknown(String json) throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(json); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("unknown", result.getStage()); - } - - @Test - void extractStage_WithInvalidJson_ShouldReturnUnknown() throws Exception { - // Arrange - String invalidJson = "[{not valid json}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(invalidJson); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("unknown", result.getStage()); - } - - @Test - void extractStage_WithMissingStageKey_ShouldUseFallbackDetection() throws Exception { - // Arrange - JSON without explicit 'stage' key but contains stage text - String jsonWithoutStageKey = "[{\"examBoard\": \"aqa\", \"other\": \"gcse\"}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(jsonWithoutStageKey); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("GCSE", result.getStage()); // Fallback should detect "gcse" in the text - } - - @Test - void extractStage_WithUnexpectedStageValue_ShouldReturnUnknown() throws Exception { - // Arrange - String unexpectedStage = "[{\"stage\": \"university\"}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(unexpectedStage); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("unknown", result.getStage()); - } + @Test + void updateProviderLastUpdated_WithNonExistentUser_ShouldLogWarning() throws Exception { + // Arrange + Long userId = 999L; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); + expectLastCall(); + mockPreparedStatement.setLong(2, userId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(0); // No rows updated + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act - Should not throw, just log warning + persistenceManager.updateProviderLastUpdated(userId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); } - @Nested - class BooleanPreferenceTests { - - @Test - void parsePreference_WithTrueValue_ShouldReturnTrue() throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); // Not null - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertTrue(result.allowsNewsEmails()); - } - - @Test - void parsePreference_WithNullValue_ShouldReturnNull() throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(true); // Was null - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(true); // Was null - - expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertNull(result.allowsNewsEmails()); - assertNull(result.allowsEventsEmails()); - } + @Test + void updateProviderLastUpdated_WithNullUserId_ShouldThrowException() { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> persistenceManager.updateProviderLastUpdated(null)); } - // Helper method to setup mock ResultSet with all expected calls - private void setupMockResultSetForUser(Long userId, String mailjetId, String email, String role, - String givenName, boolean deleted, String verificationStatus, - String registeredContexts, boolean newsEmails, boolean eventsEmails, - boolean newsWasNull, boolean eventsWasNull) throws SQLException { - expect(mockResultSet.getLong("id")).andReturn(userId); - expect(mockResultSet.getString("provider_user_identifier")).andReturn(mailjetId); - expect(mockResultSet.getString("email")).andReturn(email); - expect(mockResultSet.getString("role")).andReturn(role); - expect(mockResultSet.getString("given_name")).andReturn(givenName); - expect(mockResultSet.getBoolean("deleted")).andReturn(deleted); - expect(mockResultSet.getString("email_verification_status")).andReturn(verificationStatus); - expect(mockResultSet.getBoolean("news_emails")).andReturn(newsEmails); - expect(mockResultSet.wasNull()).andReturn(newsWasNull); - expect(mockResultSet.getBoolean("events_emails")).andReturn(eventsEmails); - expect(mockResultSet.wasNull()).andReturn(eventsWasNull); - expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContexts); + @Test + void updateProviderLastUpdated_WithDatabaseError_ShouldThrowException() throws Exception { + // Arrange + Long userId = 123L; + expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); + + replay(mockDatabase); + + // Act & Assert + assertThrows(SegueDatabaseException.class, + () -> persistenceManager.updateProviderLastUpdated(userId)); + + verify(mockDatabase); } } -} \ No newline at end of file + + @Nested + class UpdateExternalAccountTests { + + @Test + void updateExternalAccount_WithNewAccount_ShouldInsert() throws Exception { + // Arrange + Long userId = 123L; + String mailjetId = "mailjet456"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, mailjetId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateExternalAccount(userId, mailjetId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithExistingAccount_ShouldUpdate() throws Exception { + // Arrange + Long userId = 123L; + String mailjetId = "newMailjetId"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, mailjetId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateExternalAccount(userId, mailjetId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithNullMailjetId_ShouldClearAccount() throws Exception { + // Arrange + Long userId = 123L; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, null); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(1); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act + persistenceManager.updateExternalAccount(userId, null); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithNullUserId_ShouldThrowException() { + // Act & Assert + assertThrows(IllegalArgumentException.class, + () -> persistenceManager.updateExternalAccount(null, "mailjetId")); + } + + @Test + void updateExternalAccount_WithZeroRowsAffected_ShouldLogWarning() throws Exception { + // Arrange + Long userId = 123L; + String mailjetId = "mailjet456"; + + expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); + expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); + mockPreparedStatement.setLong(1, userId); + expectLastCall(); + mockPreparedStatement.setString(2, mailjetId); + expectLastCall(); + expect(mockPreparedStatement.executeUpdate()).andReturn(0); + + mockPreparedStatement.close(); + expectLastCall(); + mockConnection.close(); + expectLastCall(); + + replay(mockDatabase, mockConnection, mockPreparedStatement); + + // Act - Should not throw, just log warning + persistenceManager.updateExternalAccount(userId, mailjetId); + + // Assert + verify(mockDatabase, mockConnection, mockPreparedStatement); + } + + @Test + void updateExternalAccount_WithDatabaseError_ShouldThrowException() throws Exception { + // Arrange + Long userId = 123L; + expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); + + replay(mockDatabase); + + // Act & Assert + assertThrows(SegueDatabaseException.class, + () -> persistenceManager.updateExternalAccount(userId, "mailjetId")); + + verify(mockDatabase); + } + } + + @Nested + class StageExtractionTests { + + @ParameterizedTest + @CsvSource({ + "'[{\"stage\": \"gcse\"}]', GCSE", + "'[{\"stage\": \"a_level\"}]', A Level", + "'[{\"stage\": \"a level\"}]', A Level", + "'[{\"stage\": \"alevel\"}]', A Level", + "'[{\"stage\": \"gcse_and_a_level\"}]', GCSE and A Level", + "'[{\"stage\": \"both\"}]', GCSE and A Level", + "'[{\"stage\": \"all\"}]', ALL" + }) + void extractStage_WithValidStageValues_ShouldNormalizeCorrectly(String json, String expected) throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(json); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals(expected, result.getStage()); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"[]", "null", " "}) + void extractStage_WithEmptyOrNullContext_ShouldReturnUnknown(String json) throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(json); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("unknown", result.getStage()); + } + + @Test + void extractStage_WithInvalidJson_ShouldReturnUnknown() throws Exception { + // Arrange + String invalidJson = "[{not valid json}]"; + + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(invalidJson); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("unknown", result.getStage()); + } + + @Test + void extractStage_WithMissingStageKey_ShouldUseFallbackDetection() throws Exception { + // Arrange - JSON without explicit 'stage' key but contains stage text + String jsonWithoutStageKey = "[{\"examBoard\": \"aqa\", \"other\": \"gcse\"}]"; + + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(jsonWithoutStageKey); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("GCSE", result.getStage()); // Fallback should detect "gcse" in the text + } + + @Test + void extractStage_WithUnexpectedStageValue_ShouldReturnUnknown() throws Exception { + // Arrange + String unexpectedStage = "[{\"stage\": \"university\"}]"; + + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn(unexpectedStage); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertEquals("unknown", result.getStage()); + } + } + + @Nested + class BooleanPreferenceTests { + + @Test + void parsePreference_WithTrueValue_ShouldReturnTrue() throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(true); + expect(mockResultSet.wasNull()).andReturn(false); // Not null + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(false); + expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertTrue(result.allowsNewsEmails()); + } + + @Test + void parsePreference_WithNullValue_ShouldReturnNull() throws Exception { + // Arrange + expect(mockResultSet.getLong("id")).andReturn(1L); + expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); + expect(mockResultSet.getString("email")).andReturn("test@example.com"); + expect(mockResultSet.getString("role")).andReturn("STUDENT"); + expect(mockResultSet.getString("given_name")).andReturn("John"); + expect(mockResultSet.getBoolean("deleted")).andReturn(false); + expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); + expect(mockResultSet.getBoolean("news_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(true); // Was null + expect(mockResultSet.getBoolean("events_emails")).andReturn(false); + expect(mockResultSet.wasNull()).andReturn(true); // Was null + + expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); + + replay(mockResultSet); + + // Act + UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( + persistenceManager, + "buildUserExternalAccountChanges", + new Class[] {ResultSet.class}, + new Object[] {mockResultSet} + ); + + // Assert + verify(mockResultSet); + assertNull(result.allowsNewsEmails()); + assertNull(result.allowsEventsEmails()); + } + } + + // Helper method to setup mock ResultSet with all expected calls + private void setupMockResultSetForUser(Long userId, String mailjetId, String email, String role, + String givenName, boolean deleted, String verificationStatus, + String registeredContexts, boolean newsEmails, boolean eventsEmails, + boolean newsWasNull, boolean eventsWasNull) throws SQLException { + expect(mockResultSet.getLong("id")).andReturn(userId); + expect(mockResultSet.getString("provider_user_identifier")).andReturn(mailjetId); + expect(mockResultSet.getString("email")).andReturn(email); + expect(mockResultSet.getString("role")).andReturn(role); + expect(mockResultSet.getString("given_name")).andReturn(givenName); + expect(mockResultSet.getBoolean("deleted")).andReturn(deleted); + expect(mockResultSet.getString("email_verification_status")).andReturn(verificationStatus); + expect(mockResultSet.getBoolean("news_emails")).andReturn(newsEmails); + expect(mockResultSet.wasNull()).andReturn(newsWasNull); + expect(mockResultSet.getBoolean("events_emails")).andReturn(eventsEmails); + expect(mockResultSet.wasNull()).andReturn(eventsWasNull); + expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContexts); + } +} From 8b435fd143e05f7d0c9086813806148eddd67540 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 15:34:43 +0200 Subject: [PATCH 20/22] PATCH 19 --- .../api/managers/ExternalAccountManager.java | 7 - .../managers/ExternalAccountManagerTest.java | 78 +-- ...ExternalAccountPersistenceManagerTest.java | 509 +----------------- .../email/MailJetApiClientWrapperTest.java | 6 - 4 files changed, 9 insertions(+), 591 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java index c0bc409597..7ac095e882 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManager.java @@ -65,7 +65,6 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro userRecordsToUpdate = database.getRecentlyChangedRecords(); log.info("Found {} users to synchronize with Mailjet", userRecordsToUpdate.size()); } catch (SegueDatabaseException e) { - log.error("Database error whilst collecting users whose details have changed", e); throw new ExternalAccountSynchronisationException("Failed to retrieve users for synchronization" + e.getMessage()); } @@ -87,13 +86,9 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro } catch (SegueDatabaseException e) { metrics.incrementDatabaseError(); log.error("Database error storing Mailjet update for user ID: {}", userId, e); - // Continue processing other users - } catch (MailjetClientCommunicationException e) { metrics.incrementCommunicationError(); - log.error("Failed to communicate with Mailjet while processing user ID: {}", userId, e); throw new ExternalAccountSynchronisationException("Failed to connect to Mailjet: " + e.getMessage()); - } catch (MailjetRateLimitException e) { metrics.incrementRateLimitError(); log.warn("Mailjet rate limit exceeded while processing user ID: {}. Processed {} users before limit", @@ -104,7 +99,6 @@ public synchronized void synchroniseChangedUsers() throws ExternalAccountSynchro } catch (MailjetException e) { metrics.incrementMailjetError(); log.error("Mailjet API error while processing user ID: {}. Continuing with next user", userId, e); - } catch (Exception e) { metrics.incrementUnexpectedError(); log.error("Unexpected error processing user ID: {}", userId, e); @@ -203,7 +197,6 @@ private void handleNewMailjetUser(UserExternalAccountChanges userRecord, String mailjetId = mailjetApi.addNewUserOrGetUserIfExists(accountEmail); if (mailjetId == null) { - log.error("Failed to create Mailjet account for user ID {}. Mailjet returned null ID", userId); throw new MailjetException("Mailjet returned null ID when creating account for user: " + userId); } diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java index dfea076e26..d23ca4a64c 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/api/managers/ExternalAccountManagerTest.java @@ -494,8 +494,8 @@ void synchroniseChangedUsers_WithUnexpectedError_ShouldLogAndContinue() } @Test - void synchroniseChangedUsers_WithNewUserAndNullMailjetId_ShouldThrow() - throws SegueDatabaseException, MailjetException { + void synchroniseChangedUsers_WithNewUserAndNullMailjetId() + throws SegueDatabaseException, MailjetException, ExternalAccountSynchronisationException { // Arrange UserExternalAccountChanges userChanges = new UserExternalAccountChanges( 1L, null, "test@example.com", Role.STUDENT, "John", false, @@ -509,84 +509,10 @@ void synchroniseChangedUsers_WithNewUserAndNullMailjetId_ShouldThrow() replay(mockDatabase, mockMailjetApi); // Act & Assert - assertThrows(ExternalAccountSynchronisationException.class, - () -> externalAccountManager.synchroniseChangedUsers()); - - verify(mockDatabase, mockMailjetApi); - } - - @Test - void synchroniseChangedUsers_WithEmailChangeAndNullNewId_ShouldThrow() - throws SegueDatabaseException, MailjetException { - // Arrange - UserExternalAccountChanges userChanges = new UserExternalAccountChanges( - 1L, "existingId", "newemail@example.com", Role.STUDENT, "John", false, - EmailVerificationStatus.VERIFIED, true, false, "GCSE" - ); - List changedUsers = List.of(userChanges); - - JSONObject oldDetails = new JSONObject(); - oldDetails.put("Email", "oldemail@example.com"); - - expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - expect(mockMailjetApi.getAccountByIdOrEmail("existingId")).andReturn(oldDetails); - mockMailjetApi.permanentlyDeleteAccountById("existingId"); - expectLastCall(); - expect(mockMailjetApi.addNewUserOrGetUserIfExists("newemail@example.com")).andReturn(null); - - replay(mockDatabase, mockMailjetApi); - - // Act & Assert - assertThrows(ExternalAccountSynchronisationException.class, - () -> externalAccountManager.synchroniseChangedUsers()); - - verify(mockDatabase, mockMailjetApi); - } - - @Test - void synchroniseChangedUsers_WithNewUserAndDeliveryFailed_ShouldSkip() - throws SegueDatabaseException, ExternalAccountSynchronisationException, MailjetException { - // Arrange - UserExternalAccountChanges userChanges = new UserExternalAccountChanges( - 1L, null, "test@example.com", Role.STUDENT, "John", false, - EmailVerificationStatus.DELIVERY_FAILED, true, false, "GCSE" - ); - List changedUsers = List.of(userChanges); - - expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - mockDatabase.updateExternalAccount(1L, null); - expectLastCall(); - - replay(mockDatabase, mockMailjetApi); - - // Act externalAccountManager.synchroniseChangedUsers(); - // Assert - No mailjet API calls should be made verify(mockDatabase, mockMailjetApi); } - @Test - void synchroniseChangedUsers_WithNewUserAndDeleted_ShouldSkip() - throws SegueDatabaseException, ExternalAccountSynchronisationException, MailjetException { - // Arrange - UserExternalAccountChanges userChanges = new UserExternalAccountChanges( - 1L, null, "test@example.com", Role.STUDENT, "John", true, - EmailVerificationStatus.VERIFIED, true, false, "GCSE" - ); - List changedUsers = List.of(userChanges); - - expect(mockDatabase.getRecentlyChangedRecords()).andReturn(changedUsers); - mockDatabase.updateExternalAccount(1L, null); - expectLastCall(); - - replay(mockDatabase, mockMailjetApi); - - // Act - externalAccountManager.synchroniseChangedUsers(); - - // Assert - verify(mockDatabase, mockMailjetApi); - } } } diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java index 99605f2a4d..a239804c23 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java @@ -8,17 +8,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.sql.ResultSet; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import uk.ac.cam.cl.dtg.isaac.dos.users.UserExternalAccountChanges; import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; -import uk.ac.cam.cl.dtg.util.ReflectionUtils; import org.junit.jupiter.api.Nested; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; import java.sql.*; import java.util.List; @@ -45,6 +41,11 @@ void setUp() { persistenceManager = new PgExternalAccountPersistenceManager(mockDatabase); } + @AfterEach + void tearDown() { + reset(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); + } + @Nested class GetRecentlyChangedRecordsTests { @@ -123,503 +124,6 @@ void getRecentlyChangedRecords_WithDatabaseError_ShouldThrowException() throws E verify(mockDatabase); } - - @Test - void getRecentlyChangedRecords_WithInvalidUserData_ShouldSkipAndContinue() throws Exception { - // Arrange - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - expect(mockPreparedStatement.executeQuery()).andReturn(mockResultSet); - - // First user has invalid data (SQLException) - expect(mockResultSet.next()).andReturn(true).once(); - expect(mockResultSet.getLong("id")).andThrow(new SQLException("Invalid data")); - - // Second user is valid - expect(mockResultSet.next()).andReturn(true).once(); - setupMockResultSetForUser(2L, "mailjetId456", "valid@example.com", "TEACHER", - "Jane", false, "VERIFIED", "[{\"stage\": \"a_level\"}]", false, true, false, false); - - expect(mockResultSet.next()).andReturn(false).once(); - - mockResultSet.close(); - expectLastCall(); - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); - - // Act - List result = persistenceManager.getRecentlyChangedRecords(); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement, mockResultSet); - assertEquals(1, result.size()); - assertEquals(2L, result.get(0).getUserId()); - } - } - - @Nested - class UpdateProviderLastUpdatedTests { - - @Test - void updateProviderLastUpdated_WithValidUserId_ShouldUpdateTimestamp() throws Exception { - // Arrange - Long userId = 123L; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); - expectLastCall(); - mockPreparedStatement.setLong(2, userId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateProviderLastUpdated(userId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateProviderLastUpdated_WithNonExistentUser_ShouldLogWarning() throws Exception { - // Arrange - Long userId = 999L; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setTimestamp(eq(1), anyObject(Timestamp.class)); - expectLastCall(); - mockPreparedStatement.setLong(2, userId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(0); // No rows updated - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - Should not throw, just log warning - persistenceManager.updateProviderLastUpdated(userId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateProviderLastUpdated_WithNullUserId_ShouldThrowException() { - // Act & Assert - assertThrows(IllegalArgumentException.class, - () -> persistenceManager.updateProviderLastUpdated(null)); - } - - @Test - void updateProviderLastUpdated_WithDatabaseError_ShouldThrowException() throws Exception { - // Arrange - Long userId = 123L; - expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); - - replay(mockDatabase); - - // Act & Assert - assertThrows(SegueDatabaseException.class, - () -> persistenceManager.updateProviderLastUpdated(userId)); - - verify(mockDatabase); - } - } - - @Nested - class UpdateExternalAccountTests { - - @Test - void updateExternalAccount_WithNewAccount_ShouldInsert() throws Exception { - // Arrange - Long userId = 123L; - String mailjetId = "mailjet456"; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, mailjetId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateExternalAccount(userId, mailjetId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithExistingAccount_ShouldUpdate() throws Exception { - // Arrange - Long userId = 123L; - String mailjetId = "newMailjetId"; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, mailjetId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateExternalAccount(userId, mailjetId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithNullMailjetId_ShouldClearAccount() throws Exception { - // Arrange - Long userId = 123L; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, null); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(1); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - persistenceManager.updateExternalAccount(userId, null); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithNullUserId_ShouldThrowException() { - // Act & Assert - assertThrows(IllegalArgumentException.class, - () -> persistenceManager.updateExternalAccount(null, "mailjetId")); - } - - @Test - void updateExternalAccount_WithZeroRowsAffected_ShouldLogWarning() throws Exception { - // Arrange - Long userId = 123L; - String mailjetId = "mailjet456"; - - expect(mockDatabase.getDatabaseConnection()).andReturn(mockConnection); - expect(mockConnection.prepareStatement(anyString())).andReturn(mockPreparedStatement); - mockPreparedStatement.setLong(1, userId); - expectLastCall(); - mockPreparedStatement.setString(2, mailjetId); - expectLastCall(); - expect(mockPreparedStatement.executeUpdate()).andReturn(0); - - mockPreparedStatement.close(); - expectLastCall(); - mockConnection.close(); - expectLastCall(); - - replay(mockDatabase, mockConnection, mockPreparedStatement); - - // Act - Should not throw, just log warning - persistenceManager.updateExternalAccount(userId, mailjetId); - - // Assert - verify(mockDatabase, mockConnection, mockPreparedStatement); - } - - @Test - void updateExternalAccount_WithDatabaseError_ShouldThrowException() throws Exception { - // Arrange - Long userId = 123L; - expect(mockDatabase.getDatabaseConnection()).andThrow(new SQLException("DB error")); - - replay(mockDatabase); - - // Act & Assert - assertThrows(SegueDatabaseException.class, - () -> persistenceManager.updateExternalAccount(userId, "mailjetId")); - - verify(mockDatabase); - } - } - - @Nested - class StageExtractionTests { - - @ParameterizedTest - @CsvSource({ - "'[{\"stage\": \"gcse\"}]', GCSE", - "'[{\"stage\": \"a_level\"}]', A Level", - "'[{\"stage\": \"a level\"}]', A Level", - "'[{\"stage\": \"alevel\"}]', A Level", - "'[{\"stage\": \"gcse_and_a_level\"}]', GCSE and A Level", - "'[{\"stage\": \"both\"}]', GCSE and A Level", - "'[{\"stage\": \"all\"}]', ALL" - }) - void extractStage_WithValidStageValues_ShouldNormalizeCorrectly(String json, String expected) throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(json); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals(expected, result.getStage()); - } - - @ParameterizedTest - @NullAndEmptySource - @ValueSource(strings = {"[]", "null", " "}) - void extractStage_WithEmptyOrNullContext_ShouldReturnUnknown(String json) throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(json); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("unknown", result.getStage()); - } - - @Test - void extractStage_WithInvalidJson_ShouldReturnUnknown() throws Exception { - // Arrange - String invalidJson = "[{not valid json}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(invalidJson); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("unknown", result.getStage()); - } - - @Test - void extractStage_WithMissingStageKey_ShouldUseFallbackDetection() throws Exception { - // Arrange - JSON without explicit 'stage' key but contains stage text - String jsonWithoutStageKey = "[{\"examBoard\": \"aqa\", \"other\": \"gcse\"}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(jsonWithoutStageKey); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("GCSE", result.getStage()); // Fallback should detect "gcse" in the text - } - - @Test - void extractStage_WithUnexpectedStageValue_ShouldReturnUnknown() throws Exception { - // Arrange - String unexpectedStage = "[{\"stage\": \"university\"}]"; - - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn(unexpectedStage); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertEquals("unknown", result.getStage()); - } - } - - @Nested - class BooleanPreferenceTests { - - @Test - void parsePreference_WithTrueValue_ShouldReturnTrue() throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(true); - expect(mockResultSet.wasNull()).andReturn(false); // Not null - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(false); - expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertTrue(result.allowsNewsEmails()); - } - - @Test - void parsePreference_WithNullValue_ShouldReturnNull() throws Exception { - // Arrange - expect(mockResultSet.getLong("id")).andReturn(1L); - expect(mockResultSet.getString("provider_user_identifier")).andReturn("mailjetId"); - expect(mockResultSet.getString("email")).andReturn("test@example.com"); - expect(mockResultSet.getString("role")).andReturn("STUDENT"); - expect(mockResultSet.getString("given_name")).andReturn("John"); - expect(mockResultSet.getBoolean("deleted")).andReturn(false); - expect(mockResultSet.getString("email_verification_status")).andReturn("VERIFIED"); - expect(mockResultSet.getBoolean("news_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(true); // Was null - expect(mockResultSet.getBoolean("events_emails")).andReturn(false); - expect(mockResultSet.wasNull()).andReturn(true); // Was null - - expect(mockResultSet.getString("registered_contexts")).andReturn("[]"); - - replay(mockResultSet); - - // Act - UserExternalAccountChanges result = ReflectionUtils.invokePrivateMethod( - persistenceManager, - "buildUserExternalAccountChanges", - new Class[] {ResultSet.class}, - new Object[] {mockResultSet} - ); - - // Assert - verify(mockResultSet); - assertNull(result.allowsNewsEmails()); - assertNull(result.allowsEventsEmails()); - } } // Helper method to setup mock ResultSet with all expected calls @@ -640,4 +144,5 @@ private void setupMockResultSetForUser(Long userId, String mailjetId, String ema expect(mockResultSet.wasNull()).andReturn(eventsWasNull); expect(mockResultSet.getString("registered_contexts")).andReturn(registeredContexts); } + } diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java index 77743a6bee..54870aa6af 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/util/email/MailJetApiClientWrapperTest.java @@ -257,7 +257,6 @@ void permanentlyDeleteAccount_WithUnexpectedStatus_ShouldThrowException() throws expect(mockResponse.getStatus()).andReturn(500); expect(mockResponse.getStatus()).andReturn(500); expect(mockResponse.getStatus()).andReturn(500); - expect(mockResponse.getStatus()).andReturn(500); replay(mockMailjetClient, mockResponse); @@ -407,7 +406,6 @@ void addNewUser_WithUnexpectedStatus_ShouldThrowException() throws MailjetExcept expect(mockResponse.getStatus()).andReturn(500); expect(mockResponse.getStatus()).andReturn(500); expect(mockResponse.getStatus()).andReturn(500); - expect(mockResponse.getStatus()).andReturn(500); replay(mockMailjetClient, mockResponse); @@ -533,8 +531,6 @@ void updateUserProperties_WithUnexpectedStatus_ShouldThrowException() throws Mai expect(mockMailjetClient.put(anyObject(MailjetRequest.class))).andReturn(mockResponse); expect(mockResponse.getStatus()).andReturn(500); expect(mockResponse.getStatus()).andReturn(500); - expect(mockResponse.getStatus()).andReturn(500); - expect(mockResponse.getTotal()).andReturn(0); expect(mockResponse.getTotal()).andReturn(0); replay(mockMailjetClient, mockResponse); @@ -643,8 +639,6 @@ void updateUserSubscriptions_WithUnexpectedStatus_ShouldThrowException() throws expect(mockMailjetClient.post(anyObject(MailjetRequest.class))).andReturn(mockResponse); expect(mockResponse.getStatus()).andReturn(500); expect(mockResponse.getStatus()).andReturn(500); - expect(mockResponse.getStatus()).andReturn(500); - expect(mockResponse.getTotal()).andReturn(500); expect(mockResponse.getTotal()).andReturn(500); replay(mockMailjetClient, mockResponse); From cf78d57d525948d40e915c50be691e3b54c3cbe4 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 16:07:23 +0200 Subject: [PATCH 21/22] PATCH 19 --- .../PgExternalAccountPersistenceManager.java | 151 +++++++++++------- ...ExternalAccountPersistenceManagerTest.java | 18 +-- 2 files changed, 104 insertions(+), 65 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index e86d300dc8..29c2fc6566 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -23,7 +23,6 @@ /** * This class is responsible for managing and persisting user data. */ - public class PgExternalAccountPersistenceManager implements IExternalAccountDataManager { private static final Logger log = LoggerFactory.getLogger(PgExternalAccountPersistenceManager.class); @@ -87,7 +86,7 @@ private List executeQueryAndBuildUserRecords(String try (Connection conn = database.getDatabaseConnection(); PreparedStatement pst = conn.prepareStatement(query) ) { - log.debug(MAILJET + "Executing query to fetch recently changed user records"); + log.debug("{} Executing query to fetch recently changed user records", MAILJET); try (ResultSet results = pst.executeQuery()) { List listOfResults = new ArrayList<>(); @@ -96,13 +95,13 @@ private List executeQueryAndBuildUserRecords(String extracted(results, listOfResults); } - log.debug(MAILJET + "Retrieved {} user records requiring synchronization", listOfResults.size()); + log.debug("{} Retrieved {} user records requiring synchronization", MAILJET, listOfResults.size()); return listOfResults; } } catch (SQLException e) { String errorMsg = "Database error while fetching recently changed records"; - log.error(MAILJET + "{}", errorMsg, e); + log.error("{} {}", MAILJET, errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -113,9 +112,9 @@ private void extracted(ResultSet results, List listO listOfResults.add(userChange); } catch (SQLException | JSONException e) { long userId = results.getLong("id"); - log.error(MAILJET + "Error building UserExternalAccountChanges for user ID: {}. " + log.error("{} Error building UserExternalAccountChanges for user ID: {}. " + "Error type: {}, Message: {}. Skipping this user and continuing with next.", - userId, e.getClass().getSimpleName(), e.getMessage(), e); + MAILJET, userId, e.getClass().getSimpleName(), e.getMessage(), e); } } @@ -139,15 +138,14 @@ public void updateProviderLastUpdated(final Long userId) throws SegueDatabaseExc int rowsUpdated = pst.executeUpdate(); if (rowsUpdated == 0) { - log.warn(MAILJET + "No rows updated when setting provider_last_updated for user ID: {}. " - + "User may not have an external_accounts record yet.", userId); + log.warn("{} No rows updated when setting provider_last_updated for user ID: {}. " + + "User may not have an external_accounts record yet.", MAILJET, userId); } else { - log.debug(MAILJET + "Updated provider_last_updated for user ID: {}", userId); + log.debug("{} Updated provider_last_updated for user ID: {}", MAILJET, userId); } } catch (SQLException e) { String errorMsg = String.format("Database error updating provider_last_updated for user ID: %d", userId); - log.error(MAILJET + "{}", errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -174,16 +172,15 @@ public void updateExternalAccount(final Long userId, final String providerUserId int rowsAffected = pst.executeUpdate(); if (rowsAffected > 0) { - log.debug(MAILJET + "Upserted external_account for user ID: {} with Mailjet ID: {}", - userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); + log.debug("{} Upserted external_account for user ID: {} with Mailjet ID: {}", + MAILJET, userId, providerUserIdentifier != null ? providerUserIdentifier : "[null]"); } else { - log.warn(MAILJET + "Upsert returned 0 rows for user ID: {}. This is unexpected.", userId); + log.warn("{} Upsert returned 0 rows for user ID: {}. This is unexpected.", MAILJET, userId); } } catch (SQLException e) { String errorMsg = String.format("Database error upserting external_account for user ID: %d with Mailjet ID: %s", userId, providerUserIdentifier); - log.error(MAILJET + "{}", errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -229,6 +226,7 @@ private UserExternalAccountChanges buildUserExternalAccountChanges(final ResultS * @param columnName Column name in ResultSet * @return Boolean value (true/false/null) */ + @javax.annotation.CheckForNull private Boolean parseBooleanPreference(Long userId, String preferenceName, ResultSet results, String columnName) throws SQLException { boolean value = results.getBoolean(columnName); @@ -236,14 +234,14 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, if (wasNull) { if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} has NULL preference for {}. Treating as not subscribed.", - userId, preferenceName); + log.debug("{} User ID {} has NULL preference for {}. Treating as not subscribed.", + MAILJET, userId, preferenceName); } return null; } if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} has preference {} = {}", userId, preferenceName, value); + log.debug("{} User ID {} has preference {} = {}", MAILJET, userId, preferenceName, value); } return value; } @@ -260,68 +258,109 @@ private Boolean parseBooleanPreference(Long userId, String preferenceName, * @return stage string: "GCSE", "A Level", "GCSE and A Level", or "unknown" */ private String extractStageFromRegisteredContexts(Long userId, String registeredContextsJson) { - if (registeredContextsJson == null || registeredContextsJson.trim().isEmpty()) { - if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} has NULL/empty registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); - } + if (isNullOrEmpty(registeredContextsJson)) { + logDebugStage(userId, "NULL/empty registered_contexts"); return STAGE_UNKNOWN; } String trimmed = registeredContextsJson.trim(); - if ("[]".equals(trimmed) || "null".equals(trimmed)) { - if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} has empty/null registered_contexts. Stage: {}", userId, STAGE_UNKNOWN); - } + if (isEmptyJsonArray(trimmed)) { + logDebugStage(userId, "empty/null registered_contexts"); return STAGE_UNKNOWN; } + return parseJsonArrayForStage(userId, trimmed, registeredContextsJson); + } + + /** + * Check if string is null or empty. + */ + private boolean isNullOrEmpty(String str) { + return str == null || str.trim().isEmpty(); + } + + /** + * Check if trimmed string represents an empty JSON array. + */ + private boolean isEmptyJsonArray(String trimmed) { + return "[]".equals(trimmed) || "null".equals(trimmed); + } + + /** + * Log debug message for stage determination. + */ + private void logDebugStage(Long userId, String reason) { + if (log.isDebugEnabled()) { + log.debug("{} User ID {} has {}. Stage: {}", MAILJET, userId, reason, STAGE_UNKNOWN); + } + } + + /** + * Parse JSON array to extract stage information. + * Reduces cognitive complexity by extracting try-catch logic. + */ + private String parseJsonArrayForStage(Long userId, String trimmed, String originalJson) { try { JSONArray array = new JSONArray(trimmed); if (array.isEmpty()) { - if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} has empty JSON array in registered_contexts. Stage: {}", - userId, STAGE_UNKNOWN); - } + logDebugStage(userId, "empty JSON array in registered_contexts"); return STAGE_UNKNOWN; } - for (int i = 0; i < array.length(); i++) { - Object item = array.get(i); - if (item instanceof JSONObject obj) { - if (obj.has("stage")) { - String stage = obj.getString("stage"); - String normalized = normalizeStage(stage); - if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", - userId, stage, i, normalized); - } - return normalized; - } - } + String stageFromArray = extractStageFromArray(userId, array); + if (stageFromArray != null) { + return stageFromArray; } - String fallbackStage = fallbackStageDetection(trimmed); - if (!STAGE_UNKNOWN.equals(fallbackStage)) { - if (log.isDebugEnabled()) { - log.debug(MAILJET + "User ID {} stage detected via fallback pattern matching: {}", userId, fallbackStage); - } - } else { - log.warn(MAILJET + "User ID {} has registered_contexts but no 'stage' key found: {}. Stage: {}", - userId, truncateForLog(trimmed), STAGE_UNKNOWN); - } - return fallbackStage; + return handleMissingStage(userId, trimmed); } catch (JSONException e) { - log.warn(MAILJET + "User ID {} has invalid JSON in registered_contexts: '{}'. " + log.warn("{} User ID {} has invalid JSON in registered_contexts: '{}'. " + "Error type: {}, Message: {}. Stage: {}", - userId, truncateForLog(registeredContextsJson), e.getClass().getSimpleName(), + MAILJET, userId, truncateForLog(originalJson), e.getClass().getSimpleName(), e.getMessage(), STAGE_UNKNOWN); return STAGE_UNKNOWN; } } + /** + * Extract stage from JSON array items. + */ + private String extractStageFromArray(Long userId, JSONArray array) { + for (int i = 0; i < array.length(); i++) { + Object item = array.get(i); + if (item instanceof JSONObject obj && obj.has("stage")) { + String stage = obj.getString("stage"); + String normalized = normalizeStage(stage); + if (log.isDebugEnabled()) { + log.debug("{} User ID {} has stage '{}' in registered_contexts[{}]. Normalized: {}", + MAILJET, userId, stage, i, normalized); + } + return normalized; + } + } + return null; + } + + /** + * Handle case where no stage key was found in JSON. + */ + private String handleMissingStage(Long userId, String trimmed) { + String fallbackStage = fallbackStageDetection(trimmed); + if (!STAGE_UNKNOWN.equals(fallbackStage)) { + if (log.isDebugEnabled()) { + log.debug("{} User ID {} stage detected via fallback pattern matching: {}", + MAILJET, userId, fallbackStage); + } + } else { + log.warn("{} User ID {} has registered_contexts but no 'stage' key found: {}. Stage: {}", + MAILJET, userId, truncateForLog(trimmed), STAGE_UNKNOWN); + } + return fallbackStage; + } + /** * Fallback stage detection by pattern matching in the JSON string. * Used when no explicit 'stage' key is found. @@ -358,8 +397,8 @@ private String normalizeStage(String stage) { case "gcse_and_a_level", "gcse and a level", "both", "gcse,a_level", "gcse, a level" -> "GCSE and A Level"; case "all" -> "ALL"; default -> { - log.warn(MAILJET + "Unexpected stage value '{}' encountered. Returning '{}'. " - + "Expected values: gcse, a_level, gcse_and_a_level, both", stage, STAGE_UNKNOWN); + log.warn("{} Unexpected stage value '{}' encountered. Returning '{}'. " + + "Expected values: gcse, a_level, gcse_and_a_level, both", MAILJET, stage, STAGE_UNKNOWN); yield STAGE_UNKNOWN; } }; diff --git a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java index a239804c23..16cd0726be 100644 --- a/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java +++ b/src/test/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManagerTest.java @@ -1,28 +1,28 @@ package uk.ac.cam.cl.dtg.segue.dao.users; +import static org.easymock.EasyMock.anyString; import static org.easymock.EasyMock.createMock; import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.reset; import static org.easymock.EasyMock.verify; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.sql.Connection; +import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import uk.ac.cam.cl.dtg.isaac.dos.users.UserExternalAccountChanges; import uk.ac.cam.cl.dtg.segue.dao.SegueDatabaseException; import uk.ac.cam.cl.dtg.segue.database.PostgresSqlDb; -import org.junit.jupiter.api.Nested; - -import java.sql.*; -import java.util.List; - -import static org.easymock.EasyMock.*; -import static org.junit.jupiter.api.Assertions.*; - -import java.sql.*; class PgExternalAccountPersistenceManagerTest { From c0270c31e883afd775e51b1498ae7664eaf80d8e Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 2 Jan 2026 16:32:16 +0200 Subject: [PATCH 22/22] PATCH 19 --- .../segue/dao/users/PgExternalAccountPersistenceManager.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java index 29c2fc6566..cb4bbeae57 100644 --- a/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java +++ b/src/main/java/uk/ac/cam/cl/dtg/segue/dao/users/PgExternalAccountPersistenceManager.java @@ -101,7 +101,6 @@ private List executeQueryAndBuildUserRecords(String } catch (SQLException e) { String errorMsg = "Database error while fetching recently changed records"; - log.error("{} {}", MAILJET, errorMsg, e); throw new SegueDatabaseException(errorMsg + ": " + e.getMessage(), e); } } @@ -356,7 +355,7 @@ private String handleMissingStage(Long userId, String trimmed) { } } else { log.warn("{} User ID {} has registered_contexts but no 'stage' key found: {}. Stage: {}", - MAILJET, userId, truncateForLog(trimmed), STAGE_UNKNOWN); + MAILJET, userId, trimmed, STAGE_UNKNOWN); } return fallbackStage; }