diff --git a/grails-app/controllers/org/pih/warehouse/SecurityInterceptor.groovy b/grails-app/controllers/org/pih/warehouse/SecurityInterceptor.groovy index a47c29df03c..6e153a749d2 100644 --- a/grails-app/controllers/org/pih/warehouse/SecurityInterceptor.groovy +++ b/grails-app/controllers/org/pih/warehouse/SecurityInterceptor.groovy @@ -18,7 +18,7 @@ class SecurityInterceptor { static ArrayList controllersWithAuthUserNotRequired = ['test', 'errors'] static ArrayList actionsWithAuthUserNotRequired = ['status', 'test', 'login', 'logout', 'handleLogin', 'signup', 'handleSignup', 'json', 'updateAuthUserLocale', 'viewLogo', 'changeLocation', 'menu'] - static ArrayList controllersWithLocationNotRequired = ['categoryApi', 'productApi', 'genericApi', 'api'] + static ArrayList controllersWithLocationNotRequired = ['categoryApi', 'productApi', 'genericApi', 'api', 'customNotification'] static ArrayList actionsWithLocationNotRequired = ['status', 'test', 'login', 'logout', 'handleLogin', 'signup', 'handleSignup', 'json', 'updateAuthUserLocale', 'viewLogo', 'chooseLocation', 'menu'] def authService diff --git a/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy b/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy index dbd7159a405..610fa6de385 100644 --- a/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy +++ b/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy @@ -1120,6 +1120,12 @@ class UrlMappings { action = [GET: "getExpirationHistoryReport"] } + // in-app-notifications (custom) + "/api/custom/notifications"(controller: 'customNotification', action: 'list', method: 'GET') + "/api/custom/notifications/unread-count"(controller: 'customNotification', action: 'unreadCount', method: 'GET') + "/api/custom/notifications/read-all"(controller: 'customNotification', action: 'markAllRead', method: 'PUT') + "/api/custom/notifications/$id/read"(controller: 'customNotification', action: 'markRead', method: 'PUT') + // Error handling "401"(controller: "errors", action: "handleUnauthorized") diff --git a/grails-app/controllers/org/pih/warehouse/custom/notifications/CustomNotificationController.groovy b/grails-app/controllers/org/pih/warehouse/custom/notifications/CustomNotificationController.groovy new file mode 100644 index 00000000000..687cdc2a585 --- /dev/null +++ b/grails-app/controllers/org/pih/warehouse/custom/notifications/CustomNotificationController.groovy @@ -0,0 +1,88 @@ +package org.pih.warehouse.custom.notifications + +import grails.converters.JSON +import org.pih.warehouse.auth.AuthService + +class CustomNotificationController { + + private static final List ISO_DATE_FORMATS = [ + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ssX", + ] + + def customNotificationService + + private Date parseIsoDate(String value) { + if (!value) { + return null + } + for (String fmt : ISO_DATE_FORMATS) { + try { + return Date.parse(fmt, value) + } catch (Exception ignored) { + // try next format + } + } + log.warn "custom_notifications_unparseable_date value='${value}'" + return null + } + + def list() { + def currentUser = AuthService.currentUser + if (!currentUser) { + render status: 401 + return + } + Boolean unreadOnly = params.boolean('unreadOnly', false) + Integer limit = Math.min(Math.max(params.int('limit', 20), 1), 100) + Integer offset = Math.max(params.int('offset', 0), 0) + Date since = parseIsoDate(params.since as String) + Date updatedSince = parseIsoDate(params.updatedSince as String) + List notifications = customNotificationService.listForUser(currentUser, unreadOnly, limit, offset, since, updatedSince) + Integer unreadCount = customNotificationService.countUnread(currentUser) + def data = notifications.collect { notification -> + [ + id : notification.id, + type : notification.notificationType, + title : notification.title, + body : notification.body, + read : notification.isRead, + createdAt : notification.dateCreated, + ] + } + render([data: data, unreadCount: unreadCount] as JSON) + } + + def unreadCount() { + def currentUser = AuthService.currentUser + if (!currentUser) { + render status: 401 + return + } + render([unreadCount: customNotificationService.countUnread(currentUser)] as JSON) + } + + def markRead(String id) { + def currentUser = AuthService.currentUser + if (!currentUser) { + render status: 401 + return + } + Boolean success = customNotificationService.markRead(id, currentUser) + if (!success) { + render status: 404 + return + } + render status: 200 + } + + def markAllRead() { + def currentUser = AuthService.currentUser + if (!currentUser) { + render status: 401 + return + } + Integer count = customNotificationService.markAllRead(currentUser) + render([updatedCount: count] as JSON) + } +} diff --git a/grails-app/controllers/org/pih/warehouse/product/ProductController.groovy b/grails-app/controllers/org/pih/warehouse/product/ProductController.groovy index ce1c828c819..e7e164b5099 100644 --- a/grails-app/controllers/org/pih/warehouse/product/ProductController.groovy +++ b/grails-app/controllers/org/pih/warehouse/product/ProductController.groovy @@ -23,6 +23,7 @@ import org.pih.warehouse.core.Location import org.pih.warehouse.core.MailService import org.pih.warehouse.core.ProductPrice import org.pih.warehouse.core.RoleType +import org.pih.warehouse.custom.notifications.NotificationType import org.pih.warehouse.core.Synonym import org.pih.warehouse.core.Tag import org.pih.warehouse.core.UploadService @@ -43,6 +44,8 @@ class ProductController { def dataService def userService MailService mailService + // in-app-notifications (custom) + def notificationDispatcherService def productService def documentService def barcodeService @@ -1098,13 +1101,12 @@ class ProductController { */ private def sendProductCreatedNotification(Product productInstance) { try { - def recipientList = userService.findUsersByRoleType(RoleType.ROLE_PRODUCT_NOTIFICATION).collect { - it.email - } - if (recipientList) { + List recipients = userService.findUsersByRoleType(RoleType.ROLE_PRODUCT_NOTIFICATION) + if (recipients) { def subject = "${warehouse.message(code: 'email.productCreated.message', args: [productInstance?.name, productInstance?.createdBy?.name])}" def body = "${g.render(template: '/email/productCreated', model: [productInstance: productInstance])}" - mailService.sendHtmlMail(subject, body.toString(), recipientList) + // in-app-notifications (custom): dispatcher handles both in-app and email + notificationDispatcherService.notify(recipients, subject, body.toString(), NotificationType.PRODUCT) } } catch (Exception e) { diff --git a/grails-app/controllers/org/pih/warehouse/user/UserController.groovy b/grails-app/controllers/org/pih/warehouse/user/UserController.groovy index 57c6acdecb1..7f9d104481e 100644 --- a/grails-app/controllers/org/pih/warehouse/user/UserController.groovy +++ b/grails-app/controllers/org/pih/warehouse/user/UserController.groovy @@ -21,6 +21,7 @@ import org.pih.warehouse.core.Role import org.pih.warehouse.core.RoleType import org.pih.warehouse.core.User import org.pih.warehouse.core.UserDataService +import org.pih.warehouse.custom.notifications.NotificationType import javax.imageio.ImageIO as IIO import javax.swing.* @@ -35,6 +36,8 @@ class UserController { static allowedMethods = [save: "POST", update: "POST", delete: "GET"] MailService mailService def userService + // in-app-notifications (custom) + def notificationDispatcherService def locationService LocalizationService localizationService LocationRoleDataService locationRoleDataService @@ -212,6 +215,7 @@ class UserController { } + @Transactional def toggleActivation() { def userInstance = User.get(params.id) if (!userInstance) { @@ -483,11 +487,11 @@ class UserController { // Include the user whose status has changed users << userInstance - def recipients = users.collect { it.email } def activatedOrDeactivated = "${userInstance.active ? warehouse.message(code: 'user.activated.label') : warehouse.message(code: 'user.disabled.label')}" def subject = "${warehouse.message(code: 'email.userAccountActivated.message', args: [userInstance.username, activatedOrDeactivated])}" def body = "${g.render(template: '/email/userAccountActivated', model: [userInstance: userInstance])}" - mailService.sendHtmlMail(subject, body.toString(), recipients) + // in-app-notifications (custom): dispatcher handles both in-app and email + notificationDispatcherService.notify(users, subject, body.toString(), NotificationType.USER_ACCOUNT) flash.message = "${warehouse.message(code: 'email.sent.message')}" } catch (Exception e) { diff --git a/grails-app/domain/org/pih/warehouse/custom/notifications/CustomNotification.groovy b/grails-app/domain/org/pih/warehouse/custom/notifications/CustomNotification.groovy new file mode 100644 index 00000000000..cd4dbcbb93d --- /dev/null +++ b/grails-app/domain/org/pih/warehouse/custom/notifications/CustomNotification.groovy @@ -0,0 +1,36 @@ +package org.pih.warehouse.custom.notifications + +import org.pih.warehouse.core.User + +class CustomNotification { + + String id + User user + String notificationType + String title + String body + Boolean isRead = false + Date readAt + Date dateCreated + Date lastUpdated + + static constraints = { + notificationType blank: false, maxSize: 64 + title blank: false, maxSize: 255 + body nullable: true + readAt nullable: true + user nullable: false + } + + static mapping = { + table 'custom_notification' + id generator: 'uuid' + user column: 'user_id' + body type: 'text' + isRead column: 'is_read' + readAt column: 'read_at' + dateCreated column: 'date_created' + lastUpdated column: 'last_updated' + notificationType column: 'notification_type' + } +} diff --git a/grails-app/domain/org/pih/warehouse/custom/notifications/NotificationType.groovy b/grails-app/domain/org/pih/warehouse/custom/notifications/NotificationType.groovy new file mode 100644 index 00000000000..c1696572b8e --- /dev/null +++ b/grails-app/domain/org/pih/warehouse/custom/notifications/NotificationType.groovy @@ -0,0 +1,32 @@ +package org.pih.warehouse.custom.notifications + +/** + * Source that produced a CustomNotification. Stored as its name() in the + * custom_notification.notification_type column. + */ +enum NotificationType { + + /** Shipment shipped / received. */ + SHIPMENT, + + /** Requisition pending-approval / status update. */ + REQUISITION, + + /** Fulfillment notification. */ + FULFILLMENT, + + /** Stock / expiry alerts. */ + STOCK_ALERT, + + /** User-account creation / confirmation. */ + USER_ACCOUNT, + + /** Application-error / system notification. */ + SYSTEM, + + /** New product created. */ + PRODUCT, + + /** Fallback for events not cleanly classifiable, and the legacy email-send trigger. */ + EMAIL_TRIGGER +} diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties index 4b8333fd77a..775b71d8a94 100644 --- a/grails-app/i18n/messages.properties +++ b/grails-app/i18n/messages.properties @@ -4961,3 +4961,16 @@ customStockTransferDocument.upload.invalidFilename.error=File name is missing or react.custom.stockTransferDocuments.upload.invalidType.error=Unsupported file type. Allowed: PDF, image, Word, Excel, CSV, ZIP. react.custom.stockTransferDocuments.upload.tooLarge.error=File is too large. react.custom.stockTransferDocuments.upload.invalidFilename.error=File name is missing or invalid. + +# in-app-notifications (custom) +notifications.bell.title=Notifications +notifications.bell.empty=No notifications yet +notifications.bell.emptyUnread=No unread notifications +notifications.bell.markAllRead=Mark all as read +notifications.bell.tabUnread=Unread +notifications.bell.tabAll=All +notifications.bell.loadMore=Load more +notifications.bell.unreadCount={0} unread +notifications.bell.error=Could not load notifications +notifications.modal.noBody=No additional details +notifications.modal.close=Close diff --git a/grails-app/i18n/messages_ru.properties b/grails-app/i18n/messages_ru.properties index 50569ed5adc..50d6f53a1a0 100644 --- a/grails-app/i18n/messages_ru.properties +++ b/grails-app/i18n/messages_ru.properties @@ -4919,3 +4919,16 @@ customStockTransferDocument.upload.invalidFilename.error=\u0418\u043c\u044f \u04 react.custom.stockTransferDocuments.upload.invalidType.error=\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0439 \u0442\u0438\u043f \u0444\u0430\u0439\u043b\u0430. \u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u044b: PDF, \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435, Word, Excel, CSV, ZIP. react.custom.stockTransferDocuments.upload.tooLarge.error=\u0424\u0430\u0439\u043b \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0431\u043e\u043b\u044c\u0448\u043e\u0439. react.custom.stockTransferDocuments.upload.invalidFilename.error=\u0418\u043c\u044f \u0444\u0430\u0439\u043b\u0430 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0438\u043b\u0438 \u043d\u0435\u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e. + +# in-app-notifications (custom) +notifications.bell.title=\u0423\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f +notifications.bell.empty=\u0423\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 \u043f\u043e\u043a\u0430 \u043d\u0435\u0442 +notifications.bell.emptyUnread=\u041d\u0435\u0442 \u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u0445 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 +notifications.bell.markAllRead=\u041e\u0442\u043c\u0435\u0442\u0438\u0442\u044c \u0432\u0441\u0435 \u043a\u0430\u043a \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u0435 +notifications.bell.tabUnread=\u041d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u0435 +notifications.bell.tabAll=\u0412\u0441\u0435 +notifications.bell.loadMore=\u0417\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0435\u0449\u0451 +notifications.bell.unreadCount={0} \u043d\u0435\u043f\u0440\u043e\u0447\u0438\u0442\u0430\u043d\u043d\u044b\u0445 +notifications.bell.error=\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f +notifications.modal.noBody=\u041d\u0435\u0442 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0445 \u0441\u0432\u0435\u0434\u0435\u043d\u0438\u0439 +notifications.modal.close=\u0417\u0430\u043a\u0440\u044b\u0442\u044c diff --git a/grails-app/i18n/messages_tg.properties b/grails-app/i18n/messages_tg.properties index 1c507f79f40..c464934e9e0 100644 --- a/grails-app/i18n/messages_tg.properties +++ b/grails-app/i18n/messages_tg.properties @@ -4919,3 +4919,16 @@ customStockTransferDocument.upload.invalidFilename.error=\u041d\u043e\u043c\u043 react.custom.stockTransferDocuments.upload.invalidType.error=\u041d\u0430\u0432\u044a\u0438 \u0444\u0430\u0439\u043b\u0438 \u0434\u0430\u0441\u0442\u0433\u0438\u0440\u043d\u0430\u0448\u0430\u0432\u0430\u043d\u0434\u0430. \u0418\u04b7\u043e\u0437\u0430\u0442 \u0434\u043e\u0434\u0430 \u0448\u0443\u0434\u0430\u0430\u0441\u0442: PDF, \u0442\u0430\u0441\u0432\u0438\u0440, Word, Excel, CSV, ZIP. react.custom.stockTransferDocuments.upload.tooLarge.error=\u0424\u0430\u0439\u043b \u0430\u0437 \u04b3\u0430\u0434 \u0437\u0438\u0451\u0434 \u043a\u0430\u043b\u043e\u043d \u0430\u0441\u0442. react.custom.stockTransferDocuments.upload.invalidFilename.error=\u041d\u043e\u043c\u0438 \u0444\u0430\u0439\u043b \u043d\u0435\u0441\u0442 \u0451 \u043d\u043e\u0434\u0443\u0440\u0443\u0441\u0442 \u0430\u0441\u0442. + +# in-app-notifications (custom) +notifications.bell.title=\u041e\u0433\u043e\u04b3\u0438\u04b3\u043e +notifications.bell.empty=\u04b2\u043e\u043b\u043e \u043e\u0433\u043e\u04b3\u0438\u0435 \u043d\u0435\u0441\u0442 +notifications.bell.emptyUnread=\u041e\u0433\u043e\u04b3\u0438\u0438 \u043d\u0430\u0445\u043e\u043d\u0434\u0430 \u043d\u0435\u0441\u0442 +notifications.bell.markAllRead=\u04b2\u0430\u043c\u0430\u0440\u043e \u0445\u043e\u043d\u0434\u0430 \u0448\u0443\u0434\u0430 \u049b\u0430\u0439\u0434 \u043a\u0443\u043d\u0435\u0434 +notifications.bell.tabUnread=\u041d\u0430\u0445\u043e\u043d\u0434\u0430 +notifications.bell.tabAll=\u04b2\u0430\u043c\u0430 +notifications.bell.loadMore=\u0411\u0435\u0448\u0442\u0430\u0440 +notifications.bell.unreadCount={0} \u043d\u0430\u0445\u043e\u043d\u0434\u0430 +notifications.bell.error=\u041e\u0433\u043e\u04b3\u0438\u043d\u043e\u043c\u0430\u04b3\u043e \u0431\u043e\u0440 \u043a\u0430\u0440\u0434\u0430 \u043d\u0430\u0448\u0443\u0434\u0430\u043d\u0434 +notifications.modal.noBody=\u041c\u0430\u044a\u043b\u0443\u043c\u043e\u0442\u0438 \u0438\u043b\u043e\u0432\u0430\u0433\u04e3 \u043d\u0435\u0441\u0442 +notifications.modal.close=\u041f\u04ef\u0448\u0438\u0434\u0430\u043d diff --git a/grails-app/migrations/custom/2026-05-22-create-custom-notification.groovy b/grails-app/migrations/custom/2026-05-22-create-custom-notification.groovy new file mode 100644 index 00000000000..496b4beddbc --- /dev/null +++ b/grails-app/migrations/custom/2026-05-22-create-custom-notification.groovy @@ -0,0 +1,59 @@ +databaseChangeLog = { + changeSet(id: '2026-05-22-01-create-custom-notification', author: 'eyeseetea') { + createTable(tableName: 'custom_notification') { + column(name: 'id', type: 'varchar(255)') { + constraints(nullable: false, primaryKey: true, primaryKeyName: 'custom_notificationPK') + } + column(name: 'version', type: 'bigint') { + constraints(nullable: false) + } + column(name: 'user_id', type: 'varchar(255)') { + constraints(nullable: false) + } + column(name: 'notification_type', type: 'varchar(64)') { + constraints(nullable: false) + } + column(name: 'title', type: 'varchar(255)') { + constraints(nullable: false) + } + column(name: 'body', type: 'text') { + constraints(nullable: true) + } + column(name: 'is_read', type: 'boolean', defaultValueBoolean: false) { + constraints(nullable: false) + } + column(name: 'read_at', type: 'datetime') { + constraints(nullable: true) + } + column(name: 'date_created', type: 'datetime') { + constraints(nullable: false) + } + column(name: 'last_updated', type: 'datetime') { + constraints(nullable: false) + } + } + addForeignKeyConstraint( + baseTableName: 'custom_notification', + baseColumnNames: 'user_id', + referencedTableName: 'user', + referencedColumnNames: 'id', + constraintName: 'fk_custom_notification_user' + ) + createIndex(indexName: 'idx_custom_notification_user_unread', tableName: 'custom_notification') { + column(name: 'user_id') + column(name: 'is_read') + column(name: 'date_created') + } + createIndex(indexName: 'idx_custom_notification_user_created', tableName: 'custom_notification') { + column(name: 'user_id') + column(name: 'date_created') + } + createIndex(indexName: 'idx_custom_notification_user_updated', tableName: 'custom_notification') { + column(name: 'user_id') + column(name: 'last_updated') + } + rollback { + dropTable(tableName: 'custom_notification') + } + } +} diff --git a/grails-app/migrations/custom/changelog.groovy b/grails-app/migrations/custom/changelog.groovy index 9fc44bae2e8..9dfb1f88133 100644 --- a/grails-app/migrations/custom/changelog.groovy +++ b/grails-app/migrations/custom/changelog.groovy @@ -6,11 +6,13 @@ * * 1. Drop a file under grails-app/migrations/custom/-.groovy * with a `databaseChangeLog = { changeSet(...) { ... rollback { ... } } }` block. - * 2. Append a one-line `include file: '-.groovy'` below. + * 2. Append a one-line `include file: 'custom/-.groovy'` below. + * (Liquibase resolves include paths relative to the migrations root, not + * this aggregator's directory — so the `custom/` prefix is required.) * * Order include lines by FK dependency (target tables above holder tables). * See .claude/rules/custom-package-isolation.md for the full rules. */ databaseChangeLog = { - // No custom migrations yet — append `include file:` lines here as they ship. + include file: 'custom/2026-05-22-create-custom-notification.groovy' } diff --git a/grails-app/services/org/pih/warehouse/custom/notifications/CustomNotificationService.groovy b/grails-app/services/org/pih/warehouse/custom/notifications/CustomNotificationService.groovy new file mode 100644 index 00000000000..7fa720e234e --- /dev/null +++ b/grails-app/services/org/pih/warehouse/custom/notifications/CustomNotificationService.groovy @@ -0,0 +1,90 @@ +package org.pih.warehouse.custom.notifications + +import grails.gorm.transactions.Transactional +import org.pih.warehouse.core.User + +class CustomNotificationService { + + /** + * Record one notification per user, keyed by the User object (never by email), + * so recipients without an email address are still notified. Independent of the + * mail-enabled config and of any email send. + */ + void notifyUsers(Collection users, String title, String body, NotificationType type = NotificationType.EMAIL_TRIGGER) { + if (!users) { + return + } + String safeTitle = titleOrDefault(title) + String typeName = (type ?: NotificationType.EMAIL_TRIGGER).name() + List uniqueUsers = users.findAll { it != null }.unique { it.id } + // withTransaction participates in the caller's transaction when one exists + // (request threads) and creates a new session+transaction when there isn't one + // (Quartz/GPars background threads). withNewSession caused lock timeouts when + // the caller held a write lock on the user row (e.g. handleSignup). + CustomNotification.withTransaction { + uniqueUsers.each { User user -> + try { + CustomNotification notification = new CustomNotification( + user: user, + notificationType: typeName, + title: safeTitle, + body: body, + ) + if (!notification.save(flush: false)) { + log.error "custom_notification_record_failed user_id='${user.id}' errors=${notification.errors.allErrors*.code}" + } + } catch (Exception ex) { + // Catches runtime exceptions only. Hibernate constraint violations mark + // the transaction for rollback before this catch executes, so a DB-level + // failure can still roll back the entire batch. + log.error "custom_notification_record_failed user_id='${user?.id}' title='${title}'", ex + } + } + } + } + + private static String titleOrDefault(String title) { + String trimmed = title?.trim() + return !trimmed ? '(no subject)' : (trimmed.size() > 255 ? trimmed[0..251] + '...' : trimmed) + } + + List listForUser(User user, Boolean unreadOnly = false, Integer limit = 20, Integer offset = 0, Date since = null, Date updatedSince = null) { + CustomNotification.createCriteria().list(max: limit, offset: offset) { + eq('user', user) + if (unreadOnly) { + eq('isRead', false) + } + if (since) { + ge('dateCreated', since) + } + if (updatedSince) { + ge('lastUpdated', updatedSince) + } + order('dateCreated', 'desc') + } + } + + @Transactional + Boolean markRead(String notificationId, User user) { + CustomNotification notification = CustomNotification.get(notificationId) + if (!notification || notification.user?.id != user.id) { + return false + } + notification.isRead = true + notification.readAt = new Date() + notification.save(flush: true) + return true + } + + @Transactional + Integer markAllRead(User user) { + CustomNotification.executeUpdate( + 'update CustomNotification n set n.isRead = true, n.readAt = :now where n.user = :user and n.isRead = false', + [now: new Date(), user: user] + ) + } + + Integer countUnread(User user) { + CustomNotification.countByUserAndIsRead(user, false) + } +} diff --git a/grails-app/services/org/pih/warehouse/custom/notifications/NotificationDispatcherService.groovy b/grails-app/services/org/pih/warehouse/custom/notifications/NotificationDispatcherService.groovy new file mode 100644 index 00000000000..73456922b83 --- /dev/null +++ b/grails-app/services/org/pih/warehouse/custom/notifications/NotificationDispatcherService.groovy @@ -0,0 +1,33 @@ +package org.pih.warehouse.custom.notifications + +import org.pih.warehouse.core.User + +/** + * Single entry point for notifying users. Always records an in-app notification + * (keyed by User, so recipients without an email are still notified) and, unless + * sendEmail is false, also sends an email to the recipients that have one. + * + * Call sites that send their own specialised email — per-recipient bodies, CSV + * attachments, etc. — pass sendEmail = false so only the in-app channel is added + * and their existing email behaviour is left untouched. + */ +class NotificationDispatcherService { + + static transactional = false + + def customNotificationService + def mailService + + void notify(Collection users, String title, String body, NotificationType type, boolean sendEmail = true) { + // In-app channel: always runs, independent of mail configuration or send success. + customNotificationService.notifyUsers(users, title, body, type) + + if (sendEmail) { + List emails = users?.findAll { it?.email }*.email?.unique() + if (emails) { + // Email channel: self-gated by MailService.isMailEnabled. + mailService.sendHtmlMail(title, body, emails) + } + } + } +} diff --git a/grails-app/services/org/pih/warehouse/report/NotificationService.groovy b/grails-app/services/org/pih/warehouse/report/NotificationService.groovy index 9450ba0c3fb..decaafcd167 100644 --- a/grails-app/services/org/pih/warehouse/report/NotificationService.groovy +++ b/grails-app/services/org/pih/warehouse/report/NotificationService.groovy @@ -27,6 +27,7 @@ import org.pih.warehouse.core.Person import org.pih.warehouse.core.RoleType import org.pih.warehouse.core.User import org.pih.warehouse.core.localization.MessageLocalizer +import org.pih.warehouse.custom.notifications.NotificationType import org.pih.warehouse.data.FileGenerationService import org.pih.warehouse.requisition.Requisition import org.pih.warehouse.requisition.RequisitionSourceType @@ -41,6 +42,8 @@ class NotificationService { def dataService def userService MailService mailService + // in-app-notifications (custom) + def notificationDispatcherService GrailsApplication grailsApplication FileGenerationService fileGenerationService MessageLocalizer messageLocalizer @@ -115,14 +118,19 @@ class NotificationService { def sendAlerts(String subject, String template, Map model, List subscribers, String csv) { + String body = renderTemplate(template, model) + + // in-app-notifications (custom): record for all subscribers (incl. those with no + // email) before the email-only short-circuit below, so email-less subscribers + // still get the in-app alert. + notificationDispatcherService.notify(subscribers, subject, body, NotificationType.STOCK_ALERT, false) + Collection toList = subscribers.collect { it.email }.findAll{ it != null }.toArray() if (toList.isEmpty()) { log.info("Skipped ${subject} email because there are no subscribers") return } - String body = renderTemplate(template, model) - // Send email with attachment (if csv exists) if (csv) { mailService.sendHtmlMailWithAttachment(toList, [], subject, body, csv.bytes, "${subject}.csv", "text/csv") @@ -160,9 +168,11 @@ class NotificationService { def g = grailsApplication.mainContext.getBean('org.grails.plugins.web.taglib.ApplicationTagLib') def recipientItems = shipmentInstance.shipmentItems.groupBy {it.recipient } recipientItems.each { Person recipient, items -> + def subject = g.message(code: "email.yourItemShipped.message", args: [shipmentInstance.origin.name, shipmentInstance.destination.name, shipmentInstance.shipmentNumber]) + def body = "${g.render(template: "/email/shipmentItemShipped", model: [shipmentInstance: shipmentInstance, shipmentItems: items, recipient:recipient])}" + // in-app-notifications (custom): same body as the per-recipient email; reaches email-less recipients + notificationDispatcherService.notify([recipient], subject, body.toString(), NotificationType.SHIPMENT, false) if (emailValidator.isValid(recipient?.email)) { - def subject = g.message(code: "email.yourItemShipped.message", args: [shipmentInstance.origin.name, shipmentInstance.destination.name, shipmentInstance.shipmentNumber]) - def body = "${g.render(template: "/email/shipmentItemShipped", model: [shipmentInstance: shipmentInstance, shipmentItems: items, recipient:recipient])}" mailService.sendHtmlMail(subject, body.toString(), recipient.email) } } @@ -170,10 +180,8 @@ class NotificationService { def sendShipmentNotifications(Shipment shipmentInstance, List users, String template, String subject) { String body = renderTemplate(template, [shipmentInstance: shipmentInstance]) - List emails = users.collect { it.email } - if (!emails.empty) { - mailService.sendHtmlMail(subject, body, emails) - } + // in-app-notifications (custom): replaces the email send; dispatcher records in-app and emails users with an address + notificationDispatcherService.notify(users, subject, body, NotificationType.SHIPMENT) } void sendReceiptNotifications(PartialReceipt partialReceipt) { @@ -181,10 +189,11 @@ class NotificationService { EmailValidator emailValidator = EmailValidator.getInstance() Map> recipientItems = partialReceipt.partialReceiptItems.groupBy {it.recipient } recipientItems.each { Person recipient, items -> + String subject = messageLocalizer.localize("email.yourItemReceived.message", shipment.destination.name, shipment.shipmentNumber) + GString body = "${applicationTagLib.render(template: "/email/shipmentItemReceived", model: [shipmentInstance: shipment, receiptItems: items, recipient: recipient, receivedBy: partialReceipt.recipient])}" + // in-app-notifications (custom): same body as the per-recipient email; reaches email-less recipients + notificationDispatcherService.notify([recipient], subject, body.toString(), NotificationType.SHIPMENT, false) if (emailValidator.isValid(recipient?.email)) { - String subject = messageLocalizer.localize("email.yourItemReceived.message", shipment.destination.name, shipment.shipmentNumber) - GString body = "${applicationTagLib.render(template: "/email/shipmentItemReceived", model: [shipmentInstance: shipment, receiptItems: items, recipient: recipient, receivedBy: partialReceipt.recipient])}" - File barcodeFile = fileGenerationService.generateBarcodeFile(shipment.shipmentNumber) String barcodeFileUri = fileGenerationService.getFileUri(barcodeFile) String fileName = "GoodsReceiptNote-${shipment.shipmentNumber}.pdf" @@ -213,12 +222,12 @@ class NotificationService { if (location.active && location.supports(org.pih.warehouse.core.ActivityCode.ENABLE_NOTIFICATIONS)) { List roleTypes = [RoleType.ROLE_ERROR_NOTIFICATION] List subscribers = userService.findUsersByRoleTypes(location, roleTypes) - List emails = subscribers.collect { it.email } GrailsWrappedRuntimeException grailsException = new GrailsWrappedRuntimeException(ServletContextHolder.servletContext, exception) String body = renderTemplate("/email/applicationError", [exception: grailsException, location: location]) - mailService.sendHtmlMail("Application Error: ${exception?.message}", body, emails) + // in-app-notifications (custom): replaces the email send; dispatcher records in-app and emails users with an address + notificationDispatcherService.notify(subscribers, "Application Error: ${exception?.message}", body, NotificationType.SYSTEM) } else { log.warn("Unable to send notification because location ${location.name} is inactive or has not enabled notifications") @@ -231,10 +240,10 @@ class NotificationService { def recipients = userService.findUsersByRoleType(RoleType.ROLE_USER_NOTIFICATION) if (recipients) { def locale = new Locale(grailsApplication.config.openboxes.locale.defaultLocale) - def to = recipients?.collect { it.email }?.unique() def subject = messageSource.getMessage('email.userAccountCreated.message', [userInstance.username].toArray(), locale) def body = renderTemplate("/email/userAccountCreated", [userInstance: userInstance, additionalQuestions: additionalQuestions]) - mailService.sendHtmlMail(subject, body.toString(), to) + // in-app-notifications (custom): replaces the email send; dispatcher records in-app and emails users with an address + notificationDispatcherService.notify(recipients, subject, body.toString(), NotificationType.USER_ACCOUNT) } } catch (EmailException e) { @@ -245,12 +254,11 @@ class NotificationService { def sendUserAccountConfirmation(User userInstance, Map additionalQuestions) { try { // Send confirmation email to user - if (userInstance?.email) { - def locale = userInstance?.locale ?: new Locale(grailsApplication.config.openboxes.locale.defaultLocale) - def subject = messageSource.getMessage('email.userAccountConfirmed.message', [userInstance?.email].toArray(), locale) - def body = renderTemplate("/email/userAccountConfirmed", [userInstance: userInstance, additionalQuestions: additionalQuestions]) - mailService.sendHtmlMail(subject, body.toString(), userInstance?.email) - } + def locale = userInstance?.locale ?: new Locale(grailsApplication.config.openboxes.locale.defaultLocale) + def subject = messageSource.getMessage('email.userAccountConfirmed.message', [userInstance?.email].toArray(), locale) + def body = renderTemplate("/email/userAccountConfirmed", [userInstance: userInstance, additionalQuestions: additionalQuestions]) + // in-app-notifications (custom): replaces the email send; dispatcher records in-app and self-skips email when the user has none + notificationDispatcherService.notify([userInstance], subject, body.toString(), NotificationType.USER_ACCOUNT) } catch (EmailException e) { log.error("Unable to send confirmation email: " + e.message, e) } @@ -284,32 +292,30 @@ class NotificationService { String template = "/email/approvalsAlert" recipients.each { recipient -> + String redirectToRequestsList = "/stockMovement/list?direction=OUTBOUND&sourceType=ELECTRONIC&approver=${recipient.id}" + String body = renderTemplate(template, [requisition: requisition, redirectUrl: redirectToRequestsList]) + // in-app-notifications (custom): same body as the per-recipient email; reaches email-less recipients + notificationDispatcherService.notify([recipient], subject, body, NotificationType.REQUISITION, false) if (recipient?.email) { - String redirectToRequestsList = "/stockMovement/list?direction=OUTBOUND&sourceType=ELECTRONIC&approver=${recipient.id}" - String body = renderTemplate(template, [requisition: requisition, redirectUrl: redirectToRequestsList]) mailService.sendHtmlMail(subject, body, recipient.email) } } } void publishRequisitionStatusUpdateNotification(Requisition requisition, Person recipient) { - if (!recipient.email) { - return - } String subject = "${requisition.requestNumber} ${requisition.name}" String template = "/email/approvalsStatusChanged" String body = renderTemplate(template, [requisition: requisition]) - mailService.sendHtmlMail(subject, body, recipient.email) + // in-app-notifications (custom): replaces the email send + email guard; dispatcher records in-app and self-skips email when the recipient has none + notificationDispatcherService.notify([recipient], subject, body, NotificationType.REQUISITION) } void publishFulfillmentNotification(Person requestor, Requisition requisition) { String subject = "${requisition.requestNumber} ${requisition.name}" String template = "/email/fulfillmentAlert" - - if (requestor.email) { - String body = renderTemplate(template, [requisition: requisition]) - mailService.sendHtmlMail(subject, body, requestor.email) - } + String body = renderTemplate(template, [requisition: requisition]) + // in-app-notifications (custom): replaces the email send + email guard; dispatcher records in-app and self-skips email when the requestor has none + notificationDispatcherService.notify([requestor], subject, body, NotificationType.FULFILLMENT) } } diff --git a/grails-app/views/common/_menuicons.gsp b/grails-app/views/common/_menuicons.gsp index f04af0e7b09..2f41596e7ee 100644 --- a/grails-app/views/common/_menuicons.gsp +++ b/grails-app/views/common/_menuicons.gsp @@ -1,5 +1,6 @@ <%@page import="org.pih.warehouse.core.ActivityCode;"%>
+