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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions broker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,22 +797,25 @@ an item ID. The selected holding's call number, when available, is persisted
with its item ID and sent in `supplierInfo.callNumber`. Both fields are cleared
when a new availability lookup invalidates the previous selection.

Patron requests carry the shared-index record identifier separately in
`bib_record_id` (API: `bibRecordId`). Clients should supply the VuFind UUID or
Trove ANBD record ID when creating the borrowing request. Updates omit the field
to preserve it or send an empty string to clear it. This identifier is preserved
through retries and rerequests. Outgoing ISO18626 requests include it in
`bibliographicInfo.bibliographicRecordId` with
`bibliographicRecordIdentifierCode` set to `ReShare`; incoming lending requests
extract and persist that identifier. Other record codes are preserved, and
conflicting ReShare identifiers are rejected.

The “view in shared index” UI should use `bibRecordId` with its configured
VuFind/Trove URL and hide the link when the ID is absent or empty. It must not
substitute the supplier's local bibliographic or copy ID. Existing requests
remain without a shared-index ID unless it is explicitly supplied; the migration
does not infer one from local identifiers. Import bundles also accept
`patronRequest.bibRecordId`.
`bibRecordId` is a read-only shared-index ID (VuFind UUID or Trove ANBD record ID),
derived from `illRequest`: `bibliographicInfo.supplierUniqueRecordId` for borrowing,
or the ReShare-coded `bibliographicInfo.bibliographicRecordId` for lending.
The borrowing value follows creation, edits, metadata updates, and corrected
retry requests automatically. Clients cannot set `bibRecordId` directly; create
and update requests ignore that property, and import bundles do not accept it.
Import the source identifiers in `illRequest` instead. The legacy `bib_record_id`
database column is retained for schema compatibility and is no longer used to
determine the API value or the outgoing shared-index identifier.

The broker replaces `supplierUniqueRecordId` with the supplier's local ID in the
requests it sends to suppliers, so it also sends the shared-index ID as a
`bibliographicRecordId` with code `ReShare`. Other record codes are preserved;
incoming requests with conflicting ReShare IDs are rejected.

Clients linking to the shared index should use `bibRecordId` with their
configured VuFind/Trove URL and hide the link when it is absent. They must not
fall back to `supplierUniqueRecordId`, which on a lending request is the
supplier's local ID.

When supplier Request Item is disabled (including manual LMS workflows), a
Loan automatically attaches the selected copy as a volume before proceeding to
Expand Down
3 changes: 1 addition & 2 deletions broker/import/service/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,8 +431,7 @@ func (i Importer) normalizePatronRequest(owner string, apiBundle importoapi.Impo
bundle := importdb.PatronRequestBundle{PatronRequest: pr_db.CreatePatronRequestParams{
ID: request.Id, CreatedAt: pgTimestamp(request.CreatedAt), UpdatedAt: pgTimestamp(request.UpdatedAt),
IllRequest: request.IllRequest, IllResponse: responseValue, DueAt: dueAt,
BibRecordID: pgTextFromPtr(request.BibRecordId),
State: pr_db.PatronRequestState(request.State), Side: side,
State: pr_db.PatronRequestState(request.State), Side: side,
Patron: pgTextFromPtr(request.Patron), RequesterSymbol: pgTextFromString(request.RequesterSymbol),
SupplierSymbol: pgTextFromPtr(request.SupplierSymbol), Tenant: pgTextFromPtr(request.Tenant),
RequesterReqID: pgTextFromString(request.RequesterRequestId), NeedsAttention: request.NeedsAttention,
Expand Down
6 changes: 3 additions & 3 deletions broker/import/service/importer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1175,14 +1175,14 @@ func TestImportNotificationRejectsLegacyCondition(t *testing.T) {
assert.Contains(t, err.Error(), "condition")
}

func TestImportPatronRequestStoresBibRecordID(t *testing.T) {
func TestImportPatronRequestRejectsBibRecordID(t *testing.T) {
repo := &recordingImportRepo{patronResult: importdb.Result{Outcome: importdb.OutcomeImported}}
cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "peer-requester"}, {ID: "peer-supplier"}}}
importer := newImporter(repo, cache, nil, &recordingStateValidator{terminal: true}, fixedClock)
data := mutatePatronBundleData(t, func(bundle map[string]any) {
bundle["patronRequest"].(map[string]any)["bibRecordId"] = "shared-record"
})
_, _, err := importer.importPatronRequest(testCtx(), importdb.ConflictPolicyUpdate, "ISIL:REQ", data)
require.NoError(t, err)
assert.Equal(t, pgText("shared-record"), repo.patron.PatronRequest.BibRecordID)
require.ErrorContains(t, err, "bibRecordId")
assert.Empty(t, repo.patron.PatronRequest.ID)
}
20 changes: 4 additions & 16 deletions broker/oapi/open-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,6 @@ components:
type: string
minLength: 1
format: date-time
bibRecordId:
type: string
description: >-
Shared-index bibliographic record ID (VuFind UUID or Trove ANBD record ID),
independent of supplier bibliographic and item IDs. Sent to suppliers as
bibliographicInfo.bibliographicRecordId with identifier code ReShare.
illRequest:
type: object
description: JSON of ISO18626 request
Expand Down Expand Up @@ -925,10 +919,11 @@ components:
description: Timestamp of the patron request last update
bibRecordId:
type: string
readOnly: true
description: >-
Shared-index bibliographic record ID (VuFind UUID or Trove ANBD record ID),
independent of supplier bibliographic and item IDs. Sent to suppliers as
bibliographicInfo.bibliographicRecordId with identifier code ReShare.
Read-only shared-index record ID (VuFind UUID or Trove ANBD record ID).
Borrowing requests use illRequest.bibliographicInfo.supplierUniqueRecordId;
lending requests use the ReShare-coded bibliographicRecordId.
illRequest:
type: object
description: JSON of ISO18626 request
Expand Down Expand Up @@ -1065,13 +1060,6 @@ components:
On creation, ID of the previous borrowing request belonging to the same
requester. The previous request must not already have a successor.
Both requests are linked atomically. Not supported on update.
bibRecordId:
type: string
description: >-
Shared-index bibliographic record ID (VuFind UUID or Trove ANBD record ID),
independent of supplier bibliographic and item IDs. Sent to suppliers as
bibliographicInfo.bibliographicRecordId with identifier code ReShare.
On update, omit to preserve the current ID or send an empty string to clear it.
illRequest:
type: object
description: JSON of ISO18626 request
Expand Down
10 changes: 5 additions & 5 deletions broker/patron_request/api/api-handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,9 +570,6 @@ func (a *PatronRequestApiHandler) PutPatronRequestsId(w http.ResponseWriter, r *

existingPr.RequesterReqID = getDbText(&requesterReqId)
existingPr.IllRequest = illRequest
if newPr.BibRecordId != nil {
existingPr.BibRecordID = getDbText(newPr.BibRecordId)
}
if newPr.RequesterPickupLocationId.IsSpecified() {
existingPr.RequesterPickupLocationID = getPickupLocationID(newPr.RequesterPickupLocationId)
if !a.validatePickupLocation(w, ctx, existingPr) {
Expand Down Expand Up @@ -1475,6 +1472,10 @@ func toApiPatronRequest(r *http.Request, request pr_db.PatronRequestSearchView)
value := api.Link(r, api.Path("ill_transactions"), api.Query("requester_req_id", request.RequesterReqID.String))
illTransactionLink = &value
}
var bibRecordID *string
if id := prservice.BibRecordID(request.Side, request.IllRequest); id != "" {
bibRecordID = &id
}

pr := proapi.PatronRequest{
Id: request.ID,
Expand All @@ -1488,7 +1489,7 @@ func toApiPatronRequest(r *http.Request, request pr_db.PatronRequestSearchView)
DueDate: toLoanDate(request.DueAt),
SupplierSymbol: toString(request.SupplierSymbol),
IllRequest: request.IllRequest,
BibRecordId: toString(request.BibRecordID),
BibRecordId: bibRecordID,
RequesterRequestId: toString(request.RequesterReqID),
NeedsAttention: request.NeedsAttention,
HasCost: request.HasCost,
Expand Down Expand Up @@ -1646,7 +1647,6 @@ func buildDbPatronRequest(
RequesterPickupLocationID: getPickupLocationID(request.RequesterPickupLocationId),
SupplierSymbol: getDbText(nil),
IllRequest: illRequest,
BibRecordID: getDbText(request.BibRecordId),
Tenant: getDbText(tenant),
RequesterReqID: getDbText(&requesterReqId),
InternalNote: getDbText(request.InternalNote),
Expand Down
105 changes: 80 additions & 25 deletions broker/patron_request/api/api-handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2306,36 +2306,91 @@ func TestCreateValidatesPickupBeforePersistence(t *testing.T) {
}
}

func TestPatronRequestBibRecordIDMapping(t *testing.T) {
for _, id := range []*string{nil, new("9a18bd66-8db6-4afd-95e6-157b2d3b6d12"), new("36457383"), new("")} {
request := proapi.CreatePatronRequest{BibRecordId: id, IllRequest: validIllRequest()}
pr := buildDbPatronRequest(&request, nil, pgtype.Timestamp{}, "request-1", request.IllRequest, "", "default")
result := toApiPatronRequest(httptest.NewRequest(http.MethodGet, "/patron_requests/request-1", nil), patronRequestSearchViewFromPatronRequest(pr, false))
assert.Equal(t, id, result.BibRecordId)
}
}

func TestPutPatronRequestBibRecordID(t *testing.T) {
func TestPatronRequestBibRecordID(t *testing.T) {
reShare := []iso18626.BibliographicRecordId{{
BibliographicRecordIdentifierCode: iso18626.TypeSchemeValuePair{Text: "ReShare"},
BibliographicRecordIdentifier: "shared-record",
}}
for _, tc := range []struct {
name string
supplied *string
expected string
side pr_db.PatronRequestSide
info iso18626.BibliographicInfo
expected *string
}{
{name: "omitted preserves ID", expected: "original"},
{name: "supplied replaces ID", supplied: new("replacement"), expected: "replacement"},
{name: "empty clears ID", supplied: new("")},
{name: "borrowing uses system ID", side: prservice.SideBorrowing, info: iso18626.BibliographicInfo{SupplierUniqueRecordId: "shared-record", BibliographicRecordId: []iso18626.BibliographicRecordId{{BibliographicRecordIdentifierCode: iso18626.TypeSchemeValuePair{Text: "ReShare"}, BibliographicRecordIdentifier: "other"}}}, expected: new("shared-record")},
{name: "borrowing without system ID", side: prservice.SideBorrowing},
{name: "lending uses ReShare record ID", side: prservice.SideLending, info: iso18626.BibliographicInfo{SupplierUniqueRecordId: "local-bib", BibliographicRecordId: reShare}, expected: new("shared-record")},
{name: "lending without ReShare record ID", side: prservice.SideLending, info: iso18626.BibliographicInfo{SupplierUniqueRecordId: "local-bib"}},
} {
t.Run(tc.name, func(t *testing.T) {
repo := &PrRepoUpdateCapture{bibRecordID: pgtype.Text{String: "original", Valid: true}}
handler := NewPrApiHandler(repo, mockEventBus, mockEventRepo, tenant.NewResolver(), nil, 10)
id := "3"
body, err := json.Marshal(proapi.CreatePatronRequest{Id: &id, RequesterSymbol: &symbol, IllRequest: validIllRequest(), BibRecordId: tc.supplied})
require.NoError(t, err)
rr := httptest.NewRecorder()
handler.PutPatronRequestsId(rr, httptest.NewRequest(http.MethodPut, "/", bytes.NewReader(body)), id, proapi.PutPatronRequestsIdParams{})
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
require.NotNil(t, repo.lastUpdateParams)
assert.Equal(t, tc.expected, repo.lastUpdateParams.BibRecordID.String)
pr := pr_db.PatronRequest{ID: "request-1", Side: tc.side, BibRecordID: pgtype.Text{String: "stale-column-value", Valid: true}, IllRequest: iso18626.Request{BibliographicInfo: tc.info}}
result := toApiPatronRequest(httptest.NewRequest(http.MethodGet, "/patron_requests/request-1", nil), patronRequestSearchViewFromPatronRequest(pr, false))
assert.Equal(t, tc.expected, result.BibRecordId)
})
}
}

type bibRecordIDRepo struct {
PrRepoUpdateCapture
createdPr *pr_db.PatronRequest
}

func (r *bibRecordIDRepo) CreatePatronRequest(ctx common.ExtendedContext, params pr_db.CreatePatronRequestParams) (pr_db.PatronRequest, error) {
pr := pr_db.PatronRequest(params)
r.createdPr = &pr
return pr, nil
}

func (r *bibRecordIDRepo) GetPatronRequestSearchView(ctx common.ExtendedContext, id string) (pr_db.PatronRequestSearchView, error) {
if r.createdPr != nil {
return patronRequestSearchViewFromPatronRequest(*r.createdPr, false), nil
}
return patronRequestSearchViewFromPatronRequest(pr_db.PatronRequest(*r.lastUpdateParams), false), nil
}

func TestPatronRequestBibRecordIDReadOnly(t *testing.T) {
for _, method := range []string{http.MethodPost, http.MethodPut} {
for _, source := range []string{"source-record", ""} {
for _, supplied := range []string{"omitted", `"override"`, `""`, "null"} {
t.Run(method+"/source="+source+"/supplied="+supplied, func(t *testing.T) {
repo := &bibRecordIDRepo{PrRepoUpdateCapture: PrRepoUpdateCapture{bibRecordID: pgtype.Text{String: "stale-column-value", Valid: true}}}
handler := NewPrApiHandler(repo, mockEventBus, mockEventRepo, tenant.NewResolver(), nil, 10)
request := validIllRequest()
request.BibliographicInfo.SupplierUniqueRecordId = source
id := "3"
body, err := json.Marshal(proapi.CreatePatronRequest{Id: &id, RequesterSymbol: &symbol, IllRequest: request})
require.NoError(t, err)
var payload map[string]json.RawMessage
require.NoError(t, json.Unmarshal(body, &payload))
if supplied != "omitted" {
payload["bibRecordId"] = json.RawMessage(supplied)
}
body, err = json.Marshal(payload)
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(method, "/patron_requests/"+id, bytes.NewReader(body))
if method == http.MethodPost {
handler.PostPatronRequests(rr, req, proapi.PostPatronRequestsParams{})
require.Equal(t, http.StatusCreated, rr.Code, rr.Body.String())
require.NotNil(t, repo.createdPr)
assert.False(t, repo.createdPr.BibRecordID.Valid)
} else {
handler.PutPatronRequestsId(rr, req, id, proapi.PutPatronRequestsIdParams{})
require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
require.NotNil(t, repo.lastUpdateParams)
assert.Equal(t, repo.bibRecordID, repo.lastUpdateParams.BibRecordID)
}
var response proapi.PatronRequest
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response))
if source == "" {
assert.Nil(t, response.BibRecordId)
assert.NotContains(t, rr.Body.String(), `"bibRecordId"`)
} else {
require.NotNil(t, response.BibRecordId)
assert.Equal(t, source, *response.BibRecordId)
}
})
}
}
}
}
1 change: 0 additions & 1 deletion broker/patron_request/service/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -1439,7 +1439,6 @@ func (a *PatronRequestActionService) createSuccessorBorrowingRequest(ctx common.
successorPr.Side = pr.Side
successorPr.RequesterSymbol = pr.RequesterSymbol
successorPr.RequesterPickupLocationID = pr.RequesterPickupLocationID
successorPr.BibRecordID = pr.BibRecordID
if retry {
successorPr.SupplierSymbol = pr.SupplierSymbol
}
Expand Down
21 changes: 20 additions & 1 deletion broker/patron_request/service/bib_record_id.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,30 @@ import (
"fmt"
"strings"

pr_db "github.com/indexdata/crosslink/broker/patron_request/db"
"github.com/indexdata/crosslink/iso18626"
)

const sharedIndexRecordCode = "ReShare"

// BibRecordID returns a patron request's shared-index record ID, or "" if it has none
// or it is ambiguous. On the borrowing side that is supplierUniqueRecordId. The broker
// replaces that field with the supplier's local ID in the requests it sends and adds the
// shared-index ID under code ReShare, so on the lending side it is read from there.
func BibRecordID(side pr_db.PatronRequestSide, illRequest iso18626.Request) string {
if side == SideBorrowing {
return illRequest.BibliographicInfo.SupplierUniqueRecordId
}
if side != SideLending {
return ""
}
id, err := sharedIndexRecordID(illRequest.BibliographicInfo)
if err != nil {
return ""
}
return id
}

// sharedIndexRecordID reads only the consortium's record identifier. Supplier
// bibliographic IDs and copy IDs belong to different namespaces.
func sharedIndexRecordID(info iso18626.BibliographicInfo) (string, error) {
Expand All @@ -30,7 +49,7 @@ func sharedIndexRecordID(info iso18626.BibliographicInfo) (string, error) {
}

// setSharedIndexRecordID replaces only ReShare identifiers, including removing
// stale IDs when the local field has been cleared. It does not mutate the input slice.
// stale IDs when the source identifier has been cleared. It does not mutate the input slice.
func setSharedIndexRecordID(info *iso18626.BibliographicInfo, id string) {
var records []iso18626.BibliographicRecordId
for _, record := range info.BibliographicRecordId {
Expand Down
Loading
Loading