Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c9b86fe
chore(openspec): fix config.yaml block scalar indentation
gqcorneby May 25, 2026
9d6f329
docs(notifications): add openspec changes for in-app notifications
gqcorneby May 25, 2026
97b8437
feat(notifications): add notification domain, service, controller, API
gqcorneby May 25, 2026
221ca0b
feat(notifications): record notifications on email send
gqcorneby May 25, 2026
2b36b7a
feat(notifications): add i18n keys for notification bell
gqcorneby May 25, 2026
0f4534a
feat(notifications): add notification bell UI (React + GSP)
gqcorneby May 25, 2026
e93fdb1
test(notifications): add backend unit and integration tests
gqcorneby May 25, 2026
94fa164
feat(notifications): decouple from email via user-keyed dispatcher
gqcorneby May 25, 2026
04681f3
test(notifications): cover dispatcher and user-keyed recording
gqcorneby May 25, 2026
e9d841c
docs(notifications): record verified NotificationService sites
gqcorneby May 25, 2026
e6b75c8
refactor(notifications): namespace flag under openboxes.custom
gqcorneby May 25, 2026
0985ea7
fix(notifications): record stock alerts before email-empty guard
gqcorneby May 25, 2026
3d3ebbe
fix(notifications): use email body for in-app per-recipient alerts
gqcorneby May 25, 2026
2726b6d
fix(notifications): drop withNewSession to prevent lock timeout on si…
gqcorneby May 25, 2026
9ea7345
refactor(notifications): remove inApp enabled flag
gqcorneby May 25, 2026
b513b1f
feat(notifications): in-app alerts for user activation and product cr…
gqcorneby May 25, 2026
85413f5
chore(notifications): remove stale inApp.enabled flag references
gqcorneby May 25, 2026
5c32846
fix(notifications): open iframe links in new browser tab
gqcorneby May 25, 2026
8b0ba84
perf(notifications): index user_id+last_updated for updatedSince poll…
gqcorneby May 25, 2026
e87d501
fix(notifications): align badge position and prevent post-login redirect
gqcorneby May 25, 2026
ad30927
fix(notifications): code review corrections
gqcorneby May 25, 2026
aa0a895
docs(notifications): archive notification changes, sync spec
gqcorneby May 25, 2026
f7614bb
refactor(notifications): remove dead linkUrl field
gqcorneby May 26, 2026
f57bebc
docs(notifications): archive remove-linkurl change, sync spec
gqcorneby May 26, 2026
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
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions grails-app/controllers/org/pih/warehouse/UrlMappings.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +44,8 @@ class ProductController {
def dataService
def userService
MailService mailService
// in-app-notifications (custom)
def notificationDispatcherService
def productService
def documentService
def barcodeService
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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
Expand Down Expand Up @@ -212,6 +215,7 @@ class UserController {
}


@Transactional
def toggleActivation() {
def userInstance = User.get(params.id)
if (!userInstance) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
}
}
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions grails-app/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions grails-app/i18n/messages_ru.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions grails-app/i18n/messages_tg.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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')
}
}
}
6 changes: 4 additions & 2 deletions grails-app/migrations/custom/changelog.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
*
* 1. Drop a file under grails-app/migrations/custom/<yyyy-mm-dd>-<feature>.groovy
* with a `databaseChangeLog = { changeSet(...) { ... rollback { ... } } }` block.
* 2. Append a one-line `include file: '<yyyy-mm-dd>-<feature>.groovy'` below.
* 2. Append a one-line `include file: 'custom/<yyyy-mm-dd>-<feature>.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'
}
Loading