Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
* Licensed to the Apache Software Foundation (ASF) under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional information regarding
* copyright ownership. The ASF licenses this file to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License. You may obtain a
* copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.fineract.selfservice.notification;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.Locale;
import java.util.Objects;
import lombok.Getter;
import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.springframework.context.ApplicationEvent;

/**
* Immutable event carrying all data needed for self-service notification delivery,
* including the originating Fineract tenant context so that async listeners can
* restore multi-tenant database routing on their own thread.
*/
@Getter
public class SelfServiceNotificationEvent extends ApplicationEvent {

Expand Down Expand Up @@ -52,6 +58,20 @@ public String getTemplatePrefix() {
private final String ipAddress;
private final Locale locale;

/**
* The Fineract tenant that was active when the event was created.
* Used by async listeners to restore multi-tenant database routing.
* May be {@code null} if no tenant was available at creation time.
*/
private final FineractPlatformTenant tenant;

/**
* The business dates that were active when the event was created.
* Used by async listeners to restore date context on the worker thread.
* May be {@code null} if no business dates were available at creation time.
*/
private final HashMap<BusinessDateType, LocalDate> businessDates;

/**
* Creates a new self-service notification event.
*
Expand All @@ -69,6 +89,29 @@ public String getTemplatePrefix() {
*/
public SelfServiceNotificationEvent(Object source, Type type, Long userId, String firstName, String lastName, String username,
String email, String mobileNumber, boolean emailMode, String ipAddress, Locale locale) {
this(source, type, userId, firstName, lastName, username, email, mobileNumber, emailMode, ipAddress, locale, null, null);
}

/**
* Creates a new self-service notification event with explicit tenant context.
*
* @param source the object on which the event initially occurred (never {@code null})
* @param type the notification event type (never {@code null})
* @param userId the user identifier (never {@code null})
* @param firstName user's first name (may be {@code null})
* @param lastName user's last name (may be {@code null})
* @param username user's login username (may be {@code null})
* @param email user's email address (may be {@code null})
* @param mobileNumber user's mobile number (may be {@code null})
* @param emailMode {@code true} for email delivery, {@code false} for SMS
* @param ipAddress the originating IP address (may be {@code null})
* @param locale the locale for notification content (may be {@code null})
* @param tenant the Fineract tenant active at event creation time (may be {@code null})
* @param businessDates the business dates active at event creation time (may be {@code null})
*/
public SelfServiceNotificationEvent(Object source, Type type, Long userId, String firstName, String lastName, String username,
String email, String mobileNumber, boolean emailMode, String ipAddress, Locale locale,
FineractPlatformTenant tenant, HashMap<BusinessDateType, LocalDate> businessDates) {
super(source);
this.type = Objects.requireNonNull(type, "type must not be null");
this.userId = Objects.requireNonNull(userId, "userId must not be null");
Expand All @@ -80,6 +123,37 @@ public SelfServiceNotificationEvent(Object source, Type type, Long userId, Strin
this.emailMode = emailMode;
this.ipAddress = ipAddress;
this.locale = locale;
this.tenant = tenant;
this.businessDates = businessDates != null ? new HashMap<>(businessDates) : null;
}

/**
* Factory method that creates an event and automatically captures the current thread's
* Fineract tenant context and business dates. Use this from request-processing threads
* where the tenant context is still available.
*
* <p>This is the <strong>preferred</strong> way to create events from synchronous call sites
* (e.g. REST controllers, service methods). For events published from
* {@code TransactionSynchronization.afterCommit()} callbacks where the tenant may already
* be cleared, capture the tenant <em>before</em> registering the synchronization and pass
* it to the full constructor.
*
* @see #SelfServiceNotificationEvent(Object, Type, Long, String, String, String, String, String, boolean, String, Locale, FineractPlatformTenant, HashMap)
*/
public static SelfServiceNotificationEvent withTenantContext(Object source, Type type, Long userId, String firstName,
String lastName, String username, String email, String mobileNumber, boolean emailMode, String ipAddress, Locale locale) {
FineractPlatformTenant currentTenant = null;
HashMap<BusinessDateType, LocalDate> currentDates = null;
try {
currentTenant = ThreadLocalContextUtil.getTenant();
} catch (IllegalStateException ignored) {
}
try {
currentDates = ThreadLocalContextUtil.getBusinessDates();
} catch (IllegalArgumentException ignored) {
}
return new SelfServiceNotificationEvent(source, type, userId, firstName, lastName, username, email, mobileNumber, emailMode,
ipAddress, locale, currentTenant, currentDates);
}

/**
Expand All @@ -89,6 +163,8 @@ public SelfServiceNotificationEvent(Object source, Type type, Long userId, Strin
@Override
public String toString() {
return "SelfServiceNotificationEvent[type=" + type + ", userId=" + userId
+ ", emailMode=" + emailMode + ", locale=" + locale + "]";
+ ", emailMode=" + emailMode + ", locale=" + locale
+ ", tenant=" + (tenant != null ? tenant.getTenantIdentifier() : "null") + "]";
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
import org.apache.fineract.infrastructure.campaigns.sms.data.SmsProviderData;
import org.apache.fineract.infrastructure.campaigns.sms.service.SmsCampaignDropdownReadPlatformService;
import org.apache.fineract.infrastructure.core.domain.EmailDetail;
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
import org.apache.fineract.infrastructure.core.service.SelfServicePluginEmailService;
import org.apache.fineract.infrastructure.core.service.SmtpConfigurationUnavailableException;
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
import org.apache.fineract.infrastructure.sms.domain.SmsMessage;
import org.apache.fineract.infrastructure.sms.domain.SmsMessageRepository;
import org.apache.fineract.infrastructure.sms.domain.SmsMessageStatusType;
Expand Down Expand Up @@ -99,6 +101,7 @@ public SelfServiceNotificationService(ITemplateEngine notificationTemplateEngine
@EventListener
@Transactional
public void handleNotification(SelfServiceNotificationEvent event) {
restoreTenantContext(event);
try {
boolean globalEnabled = env.getProperty("fineract.selfservice.notification.enabled", Boolean.class, true);
if (!globalEnabled) {
Expand Down Expand Up @@ -246,4 +249,42 @@ private void releaseCooldown(SelfServiceNotificationEvent event) {
String cacheKey = event.getType().name() + ":" + event.getUserId();
notificationCooldownCache.release(cacheKey);
}

/**
* Restores the Fineract tenant context and business dates from the event onto the current
* thread. This is critical for async listeners running on the notification executor pool,
* where the original request thread's {@code ThreadLocal} tenant context may not have been
* propagated (e.g. when events are published from {@code afterCommit()} callbacks after
* the auth filter has already cleared the context).
*
* <p>The event-carried tenant takes precedence because it was captured at event creation
* time on the originating thread. The {@code TaskDecorator} in
* {@link org.apache.fineract.selfservice.notification.starter.SelfServiceNotificationConfig}
* serves as a belt-and-suspenders fallback.
*/
void restoreTenantContext(SelfServiceNotificationEvent event) {
FineractPlatformTenant eventTenant = event.getTenant();
if (eventTenant != null) {
ThreadLocalContextUtil.setTenant(eventTenant);
log.debug("Restored tenant '{}' from notification event on thread {}",
eventTenant.getTenantIdentifier(), Thread.currentThread().getName());
} else {
FineractPlatformTenant threadTenant = null;
try {
threadTenant = ThreadLocalContextUtil.getTenant();
} catch (IllegalStateException ignored) {
// getTenant() may throw on some Fineract versions
}
if (threadTenant != null) {
log.debug("Using TaskDecorator-propagated tenant '{}' on thread {}",
threadTenant.getTenantIdentifier(), Thread.currentThread().getName());
} else {
log.warn("No tenant context available for notification event {} on thread {} — "
+ "database operations may fail", event.getType(), Thread.currentThread().getName());
}
}
if (event.getBusinessDates() != null) {
ThreadLocalContextUtil.setBusinessDates(event.getBusinessDates());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@ public Executor notificationExecutor(Optional<MeterRegistry> meterRegistry, Noti
executor.setQueueCapacity(properties.getQueueCapacity());
executor.setThreadNamePrefix(properties.getThreadNamePrefix());
executor.setTaskDecorator(runnable -> {
FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
FineractPlatformTenant capturedTenant = null;
try {
capturedTenant = ThreadLocalContextUtil.getTenant();
} catch (IllegalStateException ignored) {
}
final FineractPlatformTenant tenant = capturedTenant;
HashMap<BusinessDateType, LocalDate> businessDates = currentBusinessDates();
return () -> {
try {
ThreadLocalContextUtil.setTenant(tenant);
if (tenant != null) {
ThreadLocalContextUtil.setTenant(tenant);
}
if (businessDates != null) {
ThreadLocalContextUtil.setBusinessDates(new HashMap<>(businessDates));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,22 @@ public AppSelfServiceUser confirmEnrollment(String apiRequestBodyAsJson) {
appUser.enable();
this.appSelfServiceUserRepository.saveAndFlush(appUser);

org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant capturedTenant = null;
try {
capturedTenant = org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil.getTenant();
} catch (IllegalStateException ignored) {
}
final org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant tenantSnapshot = capturedTenant;
final java.util.HashMap<org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType, java.time.LocalDate> businessDatesSnapshot;
java.util.HashMap<org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType, java.time.LocalDate> tempDates = null;
try {
java.util.HashMap<org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType, java.time.LocalDate> dates =
org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil.getBusinessDates();
tempDates = dates != null ? new java.util.HashMap<>(dates) : null;
} catch (IllegalArgumentException e) {
}
businessDatesSnapshot = tempDates;

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
Expand All @@ -546,7 +562,9 @@ public void afterCommit() {
request.getMobileNumber(),
isEmailMode(request),
null,
LocaleContextHolder.getLocale()
LocaleContextHolder.getLocale(),
tenantSnapshot,
businessDatesSnapshot
));
} catch (Exception e) {
log.warn("Failed to publish USER_ACTIVATED notification for userId={}", appUser.getId(), e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ public String authenticate(@Parameter(hidden = true) final String apiRequestBody

try (NotificationContext.Scope ignored = NotificationContext.bind(SelfServiceNotificationEvent.Type.LOGIN_FAILURE.name())) {
try {
applicationEventPublisher.publishEvent(new SelfServiceNotificationEvent(
applicationEventPublisher.publishEvent(SelfServiceNotificationEvent.withTenantContext(
this, SelfServiceNotificationEvent.Type.LOGIN_FAILURE, failedUser.getId(), failedUser.getFirstname(),
failedUser.getLastname(), request.username, failedUser.getEmail(),
mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()
Expand All @@ -149,7 +149,7 @@ mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()

try (NotificationContext.Scope ignored = NotificationContext.bind(SelfServiceNotificationEvent.Type.LOGIN_FAILURE.name())) {
try {
applicationEventPublisher.publishEvent(new SelfServiceNotificationEvent(
applicationEventPublisher.publishEvent(SelfServiceNotificationEvent.withTenantContext(
this, SelfServiceNotificationEvent.Type.LOGIN_FAILURE, failedUser.getId(), failedUser.getFirstname(),
failedUser.getLastname(), request.username, failedUser.getEmail(),
mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()
Expand All @@ -166,7 +166,7 @@ mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()
boolean emailMode = determineMode(failedUser.getEmail(), mobileNumber);
try (NotificationContext.Scope ignored = NotificationContext.bind(SelfServiceNotificationEvent.Type.LOGIN_FAILURE.name())) {
try {
applicationEventPublisher.publishEvent(new SelfServiceNotificationEvent(
applicationEventPublisher.publishEvent(SelfServiceNotificationEvent.withTenantContext(
this, SelfServiceNotificationEvent.Type.LOGIN_FAILURE, failedUser.getId(), failedUser.getFirstname(),
failedUser.getLastname(), request.username, failedUser.getEmail(),
mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()
Expand Down Expand Up @@ -219,7 +219,7 @@ mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()
boolean emailMode = determineMode(principal.getEmail(), mobileNumber);
try (NotificationContext.Scope ignored = NotificationContext.bind(SelfServiceNotificationEvent.Type.LOGIN_SUCCESS.name())) {
try {
applicationEventPublisher.publishEvent(new SelfServiceNotificationEvent(
applicationEventPublisher.publishEvent(SelfServiceNotificationEvent.withTenantContext(
this, SelfServiceNotificationEvent.Type.LOGIN_SUCCESS, principal.getId(), principal.getFirstname(),
principal.getLastname(), request.username, principal.getEmail(),
mobileNumber, emailMode, extractClientIp(httpRequest), httpRequest.getLocale()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"fineract.selfservice.notification.cooldown-seconds=5"
// Deliberately NOT setting fineract.selfservice.smtp.* to verify the exception path
})
@org.springframework.test.annotation.DirtiesContext
public class SelfServiceSmtpFallbackIntegrationTest {

@Autowired
Expand Down
Loading