From b9dd67ed074af32449fb04cf00c3537c5b018a29 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Mon, 2 Mar 2026 14:00:58 +0200 Subject: [PATCH 1/4] CROSSLINK-211 Add notification endpoint and populate notifications --- broker/oapi/open-api.yaml | 91 ++++++++ broker/patron_request/api/api-handler.go | 55 +++++ broker/patron_request/api/api-handler_test.go | 40 ++++ broker/patron_request/service/action_test.go | 16 +- .../patron_request/service/message-handler.go | 193 +++++++++++++++++ .../service/message-handler_test.go | 202 ++++++++++++++++++ .../patron_request/api/api-handler_test.go | 7 + 7 files changed, 602 insertions(+), 2 deletions(-) diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index 94780d78..b15d2b9e 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -738,6 +738,54 @@ components: - barcode - createdAt + PrNotification: + type: object + title: Notification + description: Patron request notification + properties: + id: + type: string + description: Notification id + fromSymbol: + type: string + description: Symbol of notification sender + toSymbol: + type: string + description: Symbol of notification receiver + side: + type: string + description: Patron request side + note: + type: string + description: Note of notification + cost: + type: number + format: double + description: Cost amount + currency: + type: string + description: Currency symbol + condition: + type: string + description: Condition of notification + receipt: + type: string + description: Receipt of notification + createdAt: + type: string + format: date-time + description: Notification creation date time + acknowledgedAt: + type: string + format: date-time + description: Notification acknowledged at date time + required: + - id + - fromSymbol + - toSymbol + - side + - createdAt + paths: /: get: @@ -1348,6 +1396,49 @@ paths: schema: $ref: '#/components/schemas/Error' + /patron_requests/{id}/notifications: + get: + summary: Retrieve patron request related notifications + parameters: + - in: path + name: id + schema: + type: string + required: true + description: ID of the patron request + - $ref: '#/components/parameters/Tenant' + - $ref: '#/components/parameters/Side' + - $ref: '#/components/parameters/Symbol' + tags: + - patron-requests-api + responses: + '200': + description: Successful retrieval of patron request notifications + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PrNotification' + '400': + description: Bad Request. Invalid query parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Not Found. Patron request not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /sse/events: get: summary: Subscribe to real-time notifications. Notification is send out every time when ISO18626 message is sent out diff --git a/broker/patron_request/api/api-handler.go b/broker/patron_request/api/api-handler.go index eda2fb43..4d505e6a 100644 --- a/broker/patron_request/api/api-handler.go +++ b/broker/patron_request/api/api-handler.go @@ -462,6 +462,36 @@ func (a *PatronRequestApiHandler) GetPatronRequestsIdItems(w http.ResponseWriter writeJsonResponse(w, responseItems) } +func (a *PatronRequestApiHandler) GetPatronRequestsIdNotifications(w http.ResponseWriter, r *http.Request, id string, params proapi.GetPatronRequestsIdNotificationsParams) { + symbol, err := api.GetSymbolForRequest(r, a.tenant, params.XOkapiTenant, params.Symbol) + logParams := map[string]string{"method": "GetPatronRequestsIdNotifications", "id": id, "symbol": symbol} + + if params.Side != nil { + logParams["side"] = *params.Side + } + ctx := common.CreateExtCtxWithArgs(context.Background(), &common.LoggerArgs{Other: logParams}) + + if err != nil { + addBadRequestError(ctx, w, err) + return + } + pr := a.getPatronRequestById(w, ctx, id, params.Side, symbol) + if pr == nil { + return + } + list, err := a.prRepo.GetNotificationsByPrId(ctx, pr.ID) + if err != nil { + addInternalError(ctx, w, err) + return + } + + var responseList []proapi.PrNotification + for _, n := range list { + responseList = append(responseList, toApiNotification(n)) + } + writeJsonResponse(w, responseList) +} + func writeJsonResponse(w http.ResponseWriter, resp any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -588,3 +618,28 @@ func toApiItem(item pr_db.Item) proapi.PrItem { CreatedAt: item.CreatedAt.Time, } } + +func toApiNotification(notification pr_db.Notification) proapi.PrNotification { + var ackAt *time.Time + if notification.AcknowledgedAt.Valid { + t := notification.AcknowledgedAt.Time + ackAt = &t + } + var cost *float64 + if notification.Cost.Valid { + f, _ := notification.Cost.Float64Value() + val := f.Float64 + cost = &val + } + return proapi.PrNotification{ + Id: notification.ID, + FromSymbol: notification.FromSymbol, + ToSymbol: notification.ToSymbol, + Side: string(notification.Side), + Note: toString(notification.Note), + Cost: cost, + Currency: toString(notification.Currency), + CreatedAt: notification.CreatedAt.Time, + AcknowledgedAt: ackAt, + } +} diff --git a/broker/patron_request/api/api-handler_test.go b/broker/patron_request/api/api-handler_test.go index 8c88b0e6..554fe01c 100644 --- a/broker/patron_request/api/api-handler_test.go +++ b/broker/patron_request/api/api-handler_test.go @@ -305,6 +305,42 @@ func TestGetPatronRequestsIdEventsErrorGettingEvents(t *testing.T) { assert.Contains(t, rr.Body.String(), "DB error") } +func TestGetPatronRequestsIdNotificationsNoSymbol(t *testing.T) { + handler := NewPrApiHandler(new(PrRepoError), mockEventBus, mockEventRepo, common.NewTenant(""), 10) + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.GetPatronRequestsIdNotifications(rr, req, "3", proapi.GetPatronRequestsIdNotificationsParams{Side: &proapiBorrowingSide}) + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.Contains(t, rr.Body.String(), "symbol must be specified") +} + +func TestGetPatronRequestsIdNotificationsDbError(t *testing.T) { + handler := NewPrApiHandler(new(PrRepoError), mockEventBus, mockEventRepo, common.NewTenant(""), 10) + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.GetPatronRequestsIdNotifications(rr, req, "1", proapi.GetPatronRequestsIdNotificationsParams{Symbol: &symbol, Side: &proapiBorrowingSide}) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Contains(t, rr.Body.String(), "DB error") +} + +func TestGetPatronRequestsIdNotificationsNotFoundBecauseOfSide(t *testing.T) { + handler := NewPrApiHandler(new(PrRepoError), mockEventBus, mockEventRepo, common.NewTenant(""), 10) + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.GetPatronRequestsIdNotifications(rr, req, "3", proapi.GetPatronRequestsIdNotificationsParams{Symbol: &symbol, Side: &proapiLendingSide}) + assert.Equal(t, http.StatusNotFound, rr.Code) + assert.Contains(t, rr.Body.String(), "not found") +} + +func TestGetPatronRequestsIdNotificationsErrorGettingEvents(t *testing.T) { + handler := NewPrApiHandler(new(PrRepoError), mockEventBus, new(mocks.MockEventRepositoryError), common.NewTenant(""), 10) + req, _ := http.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.GetPatronRequestsIdNotifications(rr, req, "3", proapi.GetPatronRequestsIdNotificationsParams{Symbol: &symbol, Side: &proapiBorrowingSide}) + assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Contains(t, rr.Body.String(), "DB error") +} + func TestToDbPatronRequest(t *testing.T) { handler := NewPrApiHandler(new(PrRepoError), mockEventBus, mockEventRepo, common.NewTenant(""), 10) ctx := common.CreateExtCtxWithArgs(context.Background(), &common.LoggerArgs{}) @@ -366,6 +402,10 @@ func (r *PrRepoError) GetNextHrid(ctx common.ExtendedContext, prefix string) (st return strings.ToUpper(prefix) + "-" + strconv.FormatInt(r.counter, 10), nil } +func (r *PrRepoError) GetNotificationsByPrId(ctx common.ExtendedContext, prId string) ([]pr_db.Notification, error) { + return []pr_db.Notification{}, errors.New("DB error") +} + type MockEventBus struct { mock.Mock events.EventBus diff --git a/broker/patron_request/service/action_test.go b/broker/patron_request/service/action_test.go index 05a659c7..70096cab 100644 --- a/broker/patron_request/service/action_test.go +++ b/broker/patron_request/service/action_test.go @@ -705,8 +705,9 @@ func (m *MockEventBus) CreateNotice(id string, eventName events.EventName, data type MockPrRepo struct { mock.Mock pr_db.PgPrRepo - savedPr pr_db.PatronRequest - savedItems []pr_db.Item + savedPr pr_db.PatronRequest + savedItems []pr_db.Item + savedNotifications []pr_db.Notification } func (r *MockPrRepo) GetPatronRequestById(ctx common.ExtendedContext, id string) (pr_db.PatronRequest, error) { @@ -743,6 +744,17 @@ func (r *MockPrRepo) SaveItem(ctx common.ExtendedContext, params pr_db.SaveItemP return pr_db.Item(params), nil } +func (r *MockPrRepo) SaveNotification(ctx common.ExtendedContext, params pr_db.SaveNotificationParams) (pr_db.Notification, error) { + if r.savedNotifications == nil { + r.savedNotifications = []pr_db.Notification{} + } + r.savedNotifications = append(r.savedNotifications, pr_db.Notification(params)) + if params.PrID == "error" { + return pr_db.Notification{}, errors.New("db error") + } + return pr_db.Notification(params), nil +} + type MockIso18626Handler struct { mock.Mock handler.Iso18626Handler diff --git a/broker/patron_request/service/message-handler.go b/broker/patron_request/service/message-handler.go index ccbf9fbe..baf25ea5 100644 --- a/broker/patron_request/service/message-handler.go +++ b/broker/patron_request/service/message-handler.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" "fmt" + "math" + "math/big" "strconv" "strings" "time" @@ -221,6 +223,13 @@ func (m *PatronRequestMessageHandler) updatePatronRequestAndCreateSamResponse(ct ErrorValue: err.Error(), }, err) } + err = m.extractSamNotifications(ctx, pr, sam) + if err != nil { + return createSAMResponse(sam, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ + ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, + ErrorValue: err.Error(), + }, err) + } if stateChanged { err = m.runAutoActionsOnStateEntry(ctx, pr) if err != nil { @@ -320,6 +329,13 @@ func (m *PatronRequestMessageHandler) handleRequestMessage(ctx common.ExtendedCo ErrorValue: err.Error(), }, err) } + err = m.extractRequestNotifications(ctx, pr, request) + if err != nil { + return createRequestResponse(request, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ + ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, + ErrorValue: err.Error(), + }, err) + } err = m.runAutoActionsOnStateEntry(ctx, pr) if err != nil { return createRequestResponse(request, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ @@ -406,6 +422,13 @@ func (m *PatronRequestMessageHandler) updatePatronRequestAndCreateRamResponse(ct ErrorValue: err.Error(), }, err) } + err = m.extractRamNotifications(ctx, pr, ram) + if err != nil { + return createRAMResponse(ram, iso18626.TypeMessageStatusERROR, action, &iso18626.ErrorData{ + ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, + ErrorValue: err.Error(), + }, err) + } if stateChanged { err = m.runAutoActionsOnStateEntry(ctx, pr) if err != nil { @@ -454,3 +477,173 @@ func (m *PatronRequestMessageHandler) saveItem(ctx common.ExtendedContext, prId }) return err } + +func (m *PatronRequestMessageHandler) extractSamNotifications(ctx common.ExtendedContext, pr pr_db.PatronRequest, sam iso18626.SupplyingAgencyMessage) error { + if sam.MessageInfo.Note != "" { + supSymbol, reqSymbol := getSymbolsFromHeader(sam.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + Note: getDbText(sam.MessageInfo.Note), + FromSymbol: supSymbol, + ToSymbol: reqSymbol, + Side: pr.Side, + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + if sam.MessageInfo.OfferedCosts != nil { + supSymbol, reqSymbol := getSymbolsFromHeader(sam.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + FromSymbol: supSymbol, + ToSymbol: reqSymbol, + Side: pr.Side, + Note: getDbText("Offered costs"), + Currency: getDbText(sam.MessageInfo.OfferedCosts.CurrencyCode.Text), + Cost: pgtype.Numeric{ + Valid: true, + Int: big.NewInt(int64(sam.MessageInfo.OfferedCosts.MonetaryValue.Base)), + Exp: utils.Must(safeConvertInt32(sam.MessageInfo.OfferedCosts.MonetaryValue.Exp)), + }, + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + if sam.DeliveryInfo != nil { + if sam.DeliveryInfo.DeliveryCosts != nil { + supSymbol, reqSymbol := getSymbolsFromHeader(sam.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + FromSymbol: supSymbol, + ToSymbol: reqSymbol, + Side: pr.Side, + Note: getDbText("Delivery costs"), + Currency: getDbText(sam.DeliveryInfo.DeliveryCosts.CurrencyCode.Text), + Cost: pgtype.Numeric{ + Valid: true, + Int: big.NewInt(int64(sam.DeliveryInfo.DeliveryCosts.MonetaryValue.Base)), + Exp: utils.Must(safeConvertInt32(sam.DeliveryInfo.DeliveryCosts.MonetaryValue.Exp)), + }, + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + if sam.DeliveryInfo.LoanCondition != nil { + supSymbol, reqSymbol := getSymbolsFromHeader(sam.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + FromSymbol: supSymbol, + ToSymbol: reqSymbol, + Side: pr.Side, + Condition: getDbText(sam.DeliveryInfo.LoanCondition.Text), + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + } + return nil +} + +func (m *PatronRequestMessageHandler) extractRamNotifications(ctx common.ExtendedContext, pr pr_db.PatronRequest, ram iso18626.RequestingAgencyMessage) error { + if ram.Note != "" { + supSymbol, reqSymbol := getSymbolsFromHeader(ram.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + Note: getDbText(ram.Note), + FromSymbol: reqSymbol, + ToSymbol: supSymbol, + Side: pr.Side, + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + return nil +} + +func (m *PatronRequestMessageHandler) extractRequestNotifications(ctx common.ExtendedContext, pr pr_db.PatronRequest, request iso18626.Request) error { + if request.ServiceInfo != nil && request.ServiceInfo.Note != "" { + supSymbol, reqSymbol := getSymbolsFromHeader(request.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + Note: getDbText(request.ServiceInfo.Note), + FromSymbol: reqSymbol, + ToSymbol: supSymbol, + Side: pr.Side, + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + if request.BillingInfo != nil && request.BillingInfo.MaximumCosts != nil { + supSymbol, reqSymbol := getSymbolsFromHeader(request.Header) + _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + ID: uuid.NewString(), + PrID: pr.ID, + Note: getDbText("Maximum costs"), + FromSymbol: reqSymbol, + ToSymbol: supSymbol, + Side: pr.Side, + Currency: getDbText(request.BillingInfo.MaximumCosts.CurrencyCode.Text), + Cost: pgtype.Numeric{ + Valid: true, + Int: big.NewInt(int64(request.BillingInfo.MaximumCosts.MonetaryValue.Base)), + Exp: utils.Must(safeConvertInt32(request.BillingInfo.MaximumCosts.MonetaryValue.Exp)), + }, + CreatedAt: pgtype.Timestamp{ + Valid: true, + Time: time.Now(), + }, + }) + if err != nil { + return err + } + } + return nil +} + +func getSymbolsFromHeader(header iso18626.Header) (string, string) { + return header.SupplyingAgencyId.AgencyIdType.Text + ":" + header.SupplyingAgencyId.AgencyIdValue, + header.RequestingAgencyId.AgencyIdType.Text + ":" + header.RequestingAgencyId.AgencyIdValue +} + +func safeConvertInt32(n int) (int32, error) { + if n < math.MinInt32 || n > math.MaxInt32 { + return 0, fmt.Errorf("integer out of range for int32: %d", n) + } + return int32(n), nil +} diff --git a/broker/patron_request/service/message-handler_test.go b/broker/patron_request/service/message-handler_test.go index 85150138..88f06b4c 100644 --- a/broker/patron_request/service/message-handler_test.go +++ b/broker/patron_request/service/message-handler_test.go @@ -9,6 +9,7 @@ import ( "github.com/indexdata/crosslink/broker/ill_db" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" "github.com/indexdata/crosslink/iso18626" + "github.com/indexdata/go-utils/utils" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -791,3 +792,204 @@ func TestSaveItems(t *testing.T) { assert.Equal(t, "7", mockPrRepo.savedItems[1].Barcode) assert.Equal(t, "pr1", mockPrRepo.savedItems[1].PrID) } + +func TestExtractRamNotifications(t *testing.T) { + mockPrRepo := new(MockPrRepo) + handler := CreatePatronRequestMessageHandler(mockPrRepo, *new(events.EventRepo), *new(ill_db.IllRepo), new(MockEventBus)) + // No note + err := handler.extractRamNotifications(appCtx, pr_db.PatronRequest{ID: "1"}, iso18626.RequestingAgencyMessage{}) + assert.NoError(t, err) + assert.Equal(t, 0, len(mockPrRepo.savedNotifications)) + + // Note + err = handler.extractRamNotifications(appCtx, pr_db.PatronRequest{ID: "1"}, iso18626.RequestingAgencyMessage{ + Header: iso18626.Header{ + SupplyingAgencyId: iso18626.TypeAgencyId{ + AgencyIdType: iso18626.TypeSchemeValuePair{Text: "ISIL"}, + AgencyIdValue: "SUP", + }, + RequestingAgencyId: iso18626.TypeAgencyId{ + AgencyIdType: iso18626.TypeSchemeValuePair{Text: "ISIL"}, + AgencyIdValue: "REQ", + }, + }, + Note: "save this", + }) + assert.NoError(t, err) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) + assert.Equal(t, "save this", mockPrRepo.savedNotifications[0].Note.String) + assert.Equal(t, "ISIL:REQ", mockPrRepo.savedNotifications[0].FromSymbol) + assert.Equal(t, "ISIL:SUP", mockPrRepo.savedNotifications[0].ToSymbol) + + // Error + mockPrRepo.savedNotifications = nil + err = handler.extractRamNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.RequestingAgencyMessage{ + Note: "save this", + }) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) +} + +func TestExtractRequestNotifications(t *testing.T) { + mockPrRepo := new(MockPrRepo) + handler := CreatePatronRequestMessageHandler(mockPrRepo, *new(events.EventRepo), *new(ill_db.IllRepo), new(MockEventBus)) + // No note + err := handler.extractRequestNotifications(appCtx, pr_db.PatronRequest{ID: "1"}, iso18626.Request{}) + assert.NoError(t, err) + assert.Equal(t, 0, len(mockPrRepo.savedNotifications)) + + // Note + err = handler.extractRequestNotifications(appCtx, pr_db.PatronRequest{ID: "1"}, iso18626.Request{ + Header: iso18626.Header{ + SupplyingAgencyId: iso18626.TypeAgencyId{ + AgencyIdType: iso18626.TypeSchemeValuePair{Text: "ISIL"}, + AgencyIdValue: "SUP", + }, + RequestingAgencyId: iso18626.TypeAgencyId{ + AgencyIdType: iso18626.TypeSchemeValuePair{Text: "ISIL"}, + AgencyIdValue: "REQ", + }, + }, + ServiceInfo: &iso18626.ServiceInfo{Note: "save this"}, + BillingInfo: &iso18626.BillingInfo{MaximumCosts: &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: "EUR"}, + MonetaryValue: utils.XSDDecimal{ + Base: 123, + Exp: -2, + }, + }}, + }) + assert.NoError(t, err) + assert.Equal(t, 2, len(mockPrRepo.savedNotifications)) + assert.Equal(t, "save this", mockPrRepo.savedNotifications[0].Note.String) + assert.Equal(t, "ISIL:REQ", mockPrRepo.savedNotifications[0].FromSymbol) + assert.Equal(t, "ISIL:SUP", mockPrRepo.savedNotifications[0].ToSymbol) + cost, err := mockPrRepo.savedNotifications[1].Cost.Float64Value() + assert.NoError(t, err) + assert.Equal(t, 1.23, cost.Float64) + + // Error + mockPrRepo.savedNotifications = nil + err = handler.extractRequestNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.Request{ServiceInfo: &iso18626.ServiceInfo{Note: "save this"}}) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) + + // Error + mockPrRepo.savedNotifications = nil + err = handler.extractRequestNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.Request{ + BillingInfo: &iso18626.BillingInfo{MaximumCosts: &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: "EUR"}, + MonetaryValue: utils.XSDDecimal{ + Base: 123, + Exp: -2, + }, + }}, + }) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) +} +func TestExtractSamNotifications(t *testing.T) { + mockPrRepo := new(MockPrRepo) + handler := CreatePatronRequestMessageHandler(mockPrRepo, *new(events.EventRepo), *new(ill_db.IllRepo), new(MockEventBus)) + // No note + err := handler.extractSamNotifications(appCtx, pr_db.PatronRequest{ID: "1"}, iso18626.SupplyingAgencyMessage{}) + assert.NoError(t, err) + assert.Equal(t, 0, len(mockPrRepo.savedNotifications)) + + // Note + err = handler.extractSamNotifications(appCtx, pr_db.PatronRequest{ID: "1"}, iso18626.SupplyingAgencyMessage{ + Header: iso18626.Header{ + SupplyingAgencyId: iso18626.TypeAgencyId{ + AgencyIdType: iso18626.TypeSchemeValuePair{Text: "ISIL"}, + AgencyIdValue: "SUP", + }, + RequestingAgencyId: iso18626.TypeAgencyId{ + AgencyIdType: iso18626.TypeSchemeValuePair{Text: "ISIL"}, + AgencyIdValue: "REQ", + }, + }, + MessageInfo: iso18626.MessageInfo{ + Note: "save this", + OfferedCosts: &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: "EUR"}, + MonetaryValue: utils.XSDDecimal{ + Base: 124, + Exp: -2, + }, + }, + }, + DeliveryInfo: &iso18626.DeliveryInfo{ + DeliveryCosts: &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: "EUR"}, + MonetaryValue: utils.XSDDecimal{ + Base: 125, + Exp: -2, + }, + }, + LoanCondition: &iso18626.TypeSchemeValuePair{ + Text: "library use only", + }, + }, + }) + assert.NoError(t, err) + assert.Equal(t, 4, len(mockPrRepo.savedNotifications)) + assert.Equal(t, "save this", mockPrRepo.savedNotifications[0].Note.String) + assert.Equal(t, "ISIL:SUP", mockPrRepo.savedNotifications[0].FromSymbol) + assert.Equal(t, "ISIL:REQ", mockPrRepo.savedNotifications[0].ToSymbol) + cost, err := mockPrRepo.savedNotifications[1].Cost.Float64Value() + assert.NoError(t, err) + assert.Equal(t, 1.24, cost.Float64) + cost, err = mockPrRepo.savedNotifications[2].Cost.Float64Value() + assert.NoError(t, err) + assert.Equal(t, 1.25, cost.Float64) + assert.Equal(t, "library use only", mockPrRepo.savedNotifications[3].Condition.String) + + // Error + mockPrRepo.savedNotifications = nil + err = handler.extractSamNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.SupplyingAgencyMessage{ + MessageInfo: iso18626.MessageInfo{Note: "save this"}, + }) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) + + mockPrRepo.savedNotifications = nil + err = handler.extractSamNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.SupplyingAgencyMessage{ + MessageInfo: iso18626.MessageInfo{ + OfferedCosts: &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: "EUR"}, + MonetaryValue: utils.XSDDecimal{ + Base: 124, + Exp: -2, + }, + }, + }, + }) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) + + mockPrRepo.savedNotifications = nil + err = handler.extractSamNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.SupplyingAgencyMessage{ + DeliveryInfo: &iso18626.DeliveryInfo{ + DeliveryCosts: &iso18626.TypeCosts{ + CurrencyCode: iso18626.TypeSchemeValuePair{Text: "EUR"}, + MonetaryValue: utils.XSDDecimal{ + Base: 125, + Exp: -2, + }, + }, + }, + }) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) + + mockPrRepo.savedNotifications = nil + err = handler.extractSamNotifications(appCtx, pr_db.PatronRequest{ID: "error"}, iso18626.SupplyingAgencyMessage{ + DeliveryInfo: &iso18626.DeliveryInfo{ + LoanCondition: &iso18626.TypeSchemeValuePair{ + Text: "library use only", + }, + }, + }) + assert.Equal(t, "db error", err.Error()) + assert.Equal(t, 1, len(mockPrRepo.savedNotifications)) +} diff --git a/broker/test/patron_request/api/api-handler_test.go b/broker/test/patron_request/api/api-handler_test.go index 4deef4ad..6a5f98fa 100644 --- a/broker/test/patron_request/api/api-handler_test.go +++ b/broker/test/patron_request/api/api-handler_test.go @@ -385,6 +385,13 @@ func TestActionsToCompleteState(t *testing.T) { assert.NoError(t, err, "failed to unmarshal patron request items") assert.Len(t, prItems, 0) + // Check requester patron request item count + respBytes = httpRequest(t, "GET", requesterPrPath+"/notifications"+queryParams, []byte{}, 200) + var prNotifications []proapi.PrNotification + err = json.Unmarshal(respBytes, &prNotifications) + assert.NoError(t, err, "failed to unmarshal patron request notifications") + assert.Len(t, prNotifications, 2) + // Check supplier patron request done respBytes = httpRequest(t, "GET", supplierPrPath+supQueryParams, []byte{}, 200) err = json.Unmarshal(respBytes, &foundPr) From a7df92347bc12fdf14b869291e3c68612d282858 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Mon, 2 Mar 2026 17:38:32 +0200 Subject: [PATCH 2/4] CROSSLINK-211 Improve code stability --- broker/patron_request/api/api-handler.go | 22 +++++++++++++---- .../patron_request/service/message-handler.go | 24 ++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/broker/patron_request/api/api-handler.go b/broker/patron_request/api/api-handler.go index 4d505e6a..7880d978 100644 --- a/broker/patron_request/api/api-handler.go +++ b/broker/patron_request/api/api-handler.go @@ -487,7 +487,12 @@ func (a *PatronRequestApiHandler) GetPatronRequestsIdNotifications(w http.Respon var responseList []proapi.PrNotification for _, n := range list { - responseList = append(responseList, toApiNotification(n)) + apiN, inErr := toApiNotification(n) + if inErr != nil { + addInternalError(ctx, w, inErr) + return + } + responseList = append(responseList, apiN) } writeJsonResponse(w, responseList) } @@ -619,7 +624,7 @@ func toApiItem(item pr_db.Item) proapi.PrItem { } } -func toApiNotification(notification pr_db.Notification) proapi.PrNotification { +func toApiNotification(notification pr_db.Notification) (proapi.PrNotification, error) { var ackAt *time.Time if notification.AcknowledgedAt.Valid { t := notification.AcknowledgedAt.Time @@ -627,10 +632,17 @@ func toApiNotification(notification pr_db.Notification) proapi.PrNotification { } var cost *float64 if notification.Cost.Valid { - f, _ := notification.Cost.Float64Value() + f, err := notification.Cost.Float64Value() + if err != nil { + return proapi.PrNotification{}, err + } val := f.Float64 cost = &val } + var receipt string + if notification.Receipt != "" { + receipt = string(notification.Receipt) + } return proapi.PrNotification{ Id: notification.ID, FromSymbol: notification.FromSymbol, @@ -639,7 +651,9 @@ func toApiNotification(notification pr_db.Notification) proapi.PrNotification { Note: toString(notification.Note), Cost: cost, Currency: toString(notification.Currency), + Condition: toString(notification.Condition), + Receipt: &receipt, CreatedAt: notification.CreatedAt.Time, AcknowledgedAt: ackAt, - } + }, nil } diff --git a/broker/patron_request/service/message-handler.go b/broker/patron_request/service/message-handler.go index baf25ea5..daca9c61 100644 --- a/broker/patron_request/service/message-handler.go +++ b/broker/patron_request/service/message-handler.go @@ -499,7 +499,11 @@ func (m *PatronRequestMessageHandler) extractSamNotifications(ctx common.Extende } if sam.MessageInfo.OfferedCosts != nil { supSymbol, reqSymbol := getSymbolsFromHeader(sam.Header) - _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + cost, err := safeConvertInt32(sam.MessageInfo.OfferedCosts.MonetaryValue.Exp) + if err != nil { + return err + } + _, err = m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ ID: uuid.NewString(), PrID: pr.ID, FromSymbol: supSymbol, @@ -510,7 +514,7 @@ func (m *PatronRequestMessageHandler) extractSamNotifications(ctx common.Extende Cost: pgtype.Numeric{ Valid: true, Int: big.NewInt(int64(sam.MessageInfo.OfferedCosts.MonetaryValue.Base)), - Exp: utils.Must(safeConvertInt32(sam.MessageInfo.OfferedCosts.MonetaryValue.Exp)), + Exp: cost, }, CreatedAt: pgtype.Timestamp{ Valid: true, @@ -524,7 +528,11 @@ func (m *PatronRequestMessageHandler) extractSamNotifications(ctx common.Extende if sam.DeliveryInfo != nil { if sam.DeliveryInfo.DeliveryCosts != nil { supSymbol, reqSymbol := getSymbolsFromHeader(sam.Header) - _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + cost, err := safeConvertInt32(sam.DeliveryInfo.DeliveryCosts.MonetaryValue.Exp) + if err != nil { + return err + } + _, err = m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ ID: uuid.NewString(), PrID: pr.ID, FromSymbol: supSymbol, @@ -535,7 +543,7 @@ func (m *PatronRequestMessageHandler) extractSamNotifications(ctx common.Extende Cost: pgtype.Numeric{ Valid: true, Int: big.NewInt(int64(sam.DeliveryInfo.DeliveryCosts.MonetaryValue.Base)), - Exp: utils.Must(safeConvertInt32(sam.DeliveryInfo.DeliveryCosts.MonetaryValue.Exp)), + Exp: cost, }, CreatedAt: pgtype.Timestamp{ Valid: true, @@ -611,7 +619,11 @@ func (m *PatronRequestMessageHandler) extractRequestNotifications(ctx common.Ext } if request.BillingInfo != nil && request.BillingInfo.MaximumCosts != nil { supSymbol, reqSymbol := getSymbolsFromHeader(request.Header) - _, err := m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ + cost, err := safeConvertInt32(request.BillingInfo.MaximumCosts.MonetaryValue.Exp) + if err != nil { + return err + } + _, err = m.prRepo.SaveNotification(ctx, pr_db.SaveNotificationParams{ ID: uuid.NewString(), PrID: pr.ID, Note: getDbText("Maximum costs"), @@ -622,7 +634,7 @@ func (m *PatronRequestMessageHandler) extractRequestNotifications(ctx common.Ext Cost: pgtype.Numeric{ Valid: true, Int: big.NewInt(int64(request.BillingInfo.MaximumCosts.MonetaryValue.Base)), - Exp: utils.Must(safeConvertInt32(request.BillingInfo.MaximumCosts.MonetaryValue.Exp)), + Exp: cost, }, CreatedAt: pgtype.Timestamp{ Valid: true, From 782436f882c5ca8bb1a9afcfb09c543fee606cfa Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Tue, 3 Mar 2026 13:25:41 +0200 Subject: [PATCH 3/4] CROSSLINK-211 Handle also notification messages --- .../ModuleDescriptor-template.json | 37 ++++++++++++++++++- broker/patron_request/api/api-handler.go | 7 ++-- .../patron_request/service/message-handler.go | 14 +++++++ .../patron_request/api/api-handler_test.go | 2 +- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/broker/descriptors/ModuleDescriptor-template.json b/broker/descriptors/ModuleDescriptor-template.json index 00ed819c..083820c7 100644 --- a/broker/descriptors/ModuleDescriptor-template.json +++ b/broker/descriptors/ModuleDescriptor-template.json @@ -140,6 +140,24 @@ "permissionsRequired" : [ "broker.state_model.item.get" ] + }, + { + "methods": [ + "GET" + ], + "pathPattern": "/broker/patron_requests/{id}/items", + "permissionsRequired": [ + "broker.patron_requests.item.items.get" + ] + }, + { + "methods": [ + "GET" + ], + "pathPattern": "/broker/patron_requests/{id}/notifications", + "permissionsRequired": [ + "broker.patron_requests.item.notifications.get" + ] } ] } @@ -230,6 +248,16 @@ "displayName": "Broker - read patron request events", "permissionName": "broker.patron_requests.item.events.get" }, + { + "description": "List items for a patron request", + "displayName": "Broker - read patron request items", + "permissionName": "broker.patron_requests.item.items.get" + }, + { + "description": "List notifications for a patron request", + "displayName": "Broker - read patron request notifications", + "permissionName": "broker.patron_requests.item.notifications.get" + }, { "description": "Read-only access to patron requests", "displayName": "Broker - patron requests: read", @@ -239,7 +267,10 @@ "broker.patron_requests.get", "broker.patron_requests.item.get", "broker.patron_requests.item.actions.get", - "broker.patron_requests.item.events.get" + "broker.patron_requests.item.events.get", + "broker.patron_requests.item.actions.get", + "broker.patron_requests.item.items.get", + "broker.patron_requests.item.notifications.get" ] }, { @@ -289,7 +320,9 @@ "broker.patron_requests.item.actions.get", "broker.patron_requests.item.events.get", "broker.patron_requests.item.action.post", - "broker.state_model.item.get" + "broker.state_model.item.get", + "broker.patron_requests.item.items.get", + "broker.patron_requests.item.notifications.get" ] } ] diff --git a/broker/patron_request/api/api-handler.go b/broker/patron_request/api/api-handler.go index 7880d978..6bed3168 100644 --- a/broker/patron_request/api/api-handler.go +++ b/broker/patron_request/api/api-handler.go @@ -639,9 +639,10 @@ func toApiNotification(notification pr_db.Notification) (proapi.PrNotification, val := f.Float64 cost = &val } - var receipt string + var receipt *string if notification.Receipt != "" { - receipt = string(notification.Receipt) + r := string(notification.Receipt) + receipt = &r } return proapi.PrNotification{ Id: notification.ID, @@ -652,7 +653,7 @@ func toApiNotification(notification pr_db.Notification) (proapi.PrNotification, Cost: cost, Currency: toString(notification.Currency), Condition: toString(notification.Condition), - Receipt: &receipt, + Receipt: receipt, CreatedAt: notification.CreatedAt.Time, AcknowledgedAt: ackAt, }, nil diff --git a/broker/patron_request/service/message-handler.go b/broker/patron_request/service/message-handler.go index daca9c61..cd701af7 100644 --- a/broker/patron_request/service/message-handler.go +++ b/broker/patron_request/service/message-handler.go @@ -151,6 +151,13 @@ func (m *PatronRequestMessageHandler) handleSupplyingAgencyMessage(ctx common.Ex switch sam.MessageInfo.ReasonForMessage { case iso18626.TypeReasonForMessageNotification: // Notifications are acknowledged but must not drive state transitions. + notErr := m.extractSamNotifications(ctx, pr, sam) + if notErr != nil { + return createSAMResponse(sam, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ + ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, + ErrorValue: notErr.Error(), + }, notErr) + } return createSAMResponse(sam, iso18626.TypeMessageStatusOK, nil, nil) case iso18626.TypeReasonForMessageStatusChange, iso18626.TypeReasonForMessageRequestResponse, @@ -365,6 +372,13 @@ func (m *PatronRequestMessageHandler) handleRequestingAgencyMessage(ctx common.E if ram.Action == iso18626.TypeActionNotification { // Notifications are acknowledged but must not drive state transitions. + notErr := m.extractRamNotifications(ctx, pr, ram) + if notErr != nil { + return createRAMResponse(ram, iso18626.TypeMessageStatusERROR, &ram.Action, &iso18626.ErrorData{ + ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, + ErrorValue: notErr.Error(), + }, notErr) + } return createRAMResponse(ram, iso18626.TypeMessageStatusOK, &ram.Action, nil, nil) } diff --git a/broker/test/patron_request/api/api-handler_test.go b/broker/test/patron_request/api/api-handler_test.go index 6a5f98fa..11258e80 100644 --- a/broker/test/patron_request/api/api-handler_test.go +++ b/broker/test/patron_request/api/api-handler_test.go @@ -390,7 +390,7 @@ func TestActionsToCompleteState(t *testing.T) { var prNotifications []proapi.PrNotification err = json.Unmarshal(respBytes, &prNotifications) assert.NoError(t, err, "failed to unmarshal patron request notifications") - assert.Len(t, prNotifications, 2) + assert.True(t, len(prNotifications) >= 4) // Check supplier patron request done respBytes = httpRequest(t, "GET", supplierPrPath+supQueryParams, []byte{}, 200) From a859d230403a987c204f81afe1d102576d2c03ae Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Wed, 4 Mar 2026 08:38:01 +0200 Subject: [PATCH 4/4] CROSSLINK-211 Only log notification saving errors --- .../patron_request/service/message-handler.go | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/broker/patron_request/service/message-handler.go b/broker/patron_request/service/message-handler.go index cd701af7..bb3d5af6 100644 --- a/broker/patron_request/service/message-handler.go +++ b/broker/patron_request/service/message-handler.go @@ -153,10 +153,7 @@ func (m *PatronRequestMessageHandler) handleSupplyingAgencyMessage(ctx common.Ex // Notifications are acknowledged but must not drive state transitions. notErr := m.extractSamNotifications(ctx, pr, sam) if notErr != nil { - return createSAMResponse(sam, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ - ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, - ErrorValue: notErr.Error(), - }, notErr) + ctx.Logger().Error("failed to save sam notifications", "error", notErr) } return createSAMResponse(sam, iso18626.TypeMessageStatusOK, nil, nil) case iso18626.TypeReasonForMessageStatusChange, @@ -232,10 +229,7 @@ func (m *PatronRequestMessageHandler) updatePatronRequestAndCreateSamResponse(ct } err = m.extractSamNotifications(ctx, pr, sam) if err != nil { - return createSAMResponse(sam, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ - ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, - ErrorValue: err.Error(), - }, err) + ctx.Logger().Error("failed to save sam notifications", "error", err) } if stateChanged { err = m.runAutoActionsOnStateEntry(ctx, pr) @@ -338,10 +332,7 @@ func (m *PatronRequestMessageHandler) handleRequestMessage(ctx common.ExtendedCo } err = m.extractRequestNotifications(ctx, pr, request) if err != nil { - return createRequestResponse(request, iso18626.TypeMessageStatusERROR, &iso18626.ErrorData{ - ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, - ErrorValue: err.Error(), - }, err) + ctx.Logger().Error("failed to save request notifications", "error", err) } err = m.runAutoActionsOnStateEntry(ctx, pr) if err != nil { @@ -374,10 +365,7 @@ func (m *PatronRequestMessageHandler) handleRequestingAgencyMessage(ctx common.E // Notifications are acknowledged but must not drive state transitions. notErr := m.extractRamNotifications(ctx, pr, ram) if notErr != nil { - return createRAMResponse(ram, iso18626.TypeMessageStatusERROR, &ram.Action, &iso18626.ErrorData{ - ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, - ErrorValue: notErr.Error(), - }, notErr) + ctx.Logger().Error("failed to save ram notifications", "error", notErr) } return createRAMResponse(ram, iso18626.TypeMessageStatusOK, &ram.Action, nil, nil) } @@ -438,10 +426,7 @@ func (m *PatronRequestMessageHandler) updatePatronRequestAndCreateRamResponse(ct } err = m.extractRamNotifications(ctx, pr, ram) if err != nil { - return createRAMResponse(ram, iso18626.TypeMessageStatusERROR, action, &iso18626.ErrorData{ - ErrorType: iso18626.TypeErrorTypeUnrecognisedDataValue, - ErrorValue: err.Error(), - }, err) + ctx.Logger().Error("failed to save ram notifications", "error", err) } if stateChanged { err = m.runAutoActionsOnStateEntry(ctx, pr)