Skip to content

Commit 0cdda4d

Browse files
CROSSLINK-266 Check incoming duplication messages (#660)
* CROSSLINK-266 Check incoming duplication messages * CROSSLINK-266 Add duplicate action outcome * CROSSLINK-266 Fix migration script * CROSSLINK-266 Fix copilot comments * CROSSLINK-266 Add duplicate test * CROSSLINK-266 Use CQL to search duplicates * CROSSLINK-266 Use exact match for title and patron id
1 parent 9ebf5d2 commit 0cdda4d

17 files changed

Lines changed: 444 additions & 19 deletions

broker/handler/iso18626-handler.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
"sync"
1212
"time"
1313

14+
"github.com/indexdata/cql-go/cqlbuilder"
1415
"github.com/indexdata/crosslink/broker/catalog"
1516
"github.com/indexdata/crosslink/broker/shim"
17+
"github.com/indexdata/crosslink/directory"
1618

1719
"github.com/indexdata/crosslink/broker/adapter"
1820

@@ -33,6 +35,13 @@ var brokerSymbol = utils.GetEnv("BROKER_SYMBOL", "ISIL:BROKER")
3335
const HANDLER_COMP = "iso18626_handler"
3436
const ORIGINAL_INCOMING_MESSAGE = "originalIncomingMessage"
3537

38+
var lookupQueryBuilder = utils.Must(catalog.NewQueryBuilderGen(&directory.QueryConfig{
39+
Identifier: new("supplier_unique_record_id = {term}"),
40+
Type: new(directory.Cql),
41+
}))
42+
43+
var queryTimeFormat = "2006-01-02 15:04:05"
44+
3645
type ErrorValue string
3746

3847
const (
@@ -47,6 +56,7 @@ const (
4756
InvalidAction ErrorValue = "invalid action"
4857
InvalidStatus ErrorValue = "invalid status"
4958
InvalidReason ErrorValue = "invalid reason"
59+
ReqIsDuplicate ErrorValue = "duplicate request: an ILL request with the same patron, item, and service type was already submitted within the configured time window"
5060
)
5161

5262
const PublicFailedToProcessReqMsg = "failed to process request"
@@ -64,6 +74,7 @@ var ErrReqAgencyNotFound = errors.New(string(ReqAgencyNotFound))
6474
var ErrInvalidAction = errors.New(string(InvalidAction))
6575
var ErrInvalidStatus = errors.New(string(InvalidStatus))
6676
var ErrInvalidReason = errors.New(string(InvalidReason))
77+
var ErrDuplicateRequest = errors.New(string(ReqIsDuplicate))
6778

6879
var waitingReqs = map[string]RequestWait{}
6980

@@ -139,6 +150,10 @@ func Iso18626PostHandler(repo ill_db.IllRepo, eventBus events.EventBus, dirAdapt
139150
}
140151

141152
func handleNewRequest(ctx common.ExtendedContext, request *iso18626.Request, repo ill_db.IllRepo, requesterSymbol pgtype.Text, peers []ill_db.Peer) (string, error) {
153+
if err := checkDuplicateRequest(ctx, request, repo, requesterSymbol.String, peers[0]); err != nil {
154+
return "", err
155+
}
156+
142157
supplierSymbol := createPgText(request.Header.SupplyingAgencyId.AgencyIdType.Text + ":" + request.Header.SupplyingAgencyId.AgencyIdValue)
143158
requesterRequestId := createPgText(request.Header.RequestingAgencyRequestId)
144159
supplierRequestId := createPgText(request.Header.SupplyingAgencyRequestId)
@@ -173,6 +188,61 @@ func handleNewRequest(ctx common.ExtendedContext, request *iso18626.Request, rep
173188
return id, err
174189
}
175190

191+
func checkDuplicateRequest(ctx common.ExtendedContext, request *iso18626.Request, repo ill_db.IllRepo, requesterSymbol string, peer ill_db.Peer) error {
192+
windowHours := peer.CustomData.DuplicateCheckWindowHours
193+
if windowHours == nil || *windowHours <= 0 {
194+
return nil
195+
}
196+
197+
patronId := ""
198+
if request.PatronInfo != nil {
199+
patronId = request.PatronInfo.PatronId
200+
}
201+
202+
if patronId == "" {
203+
return nil
204+
}
205+
206+
lookupParams := catalog.LookupParamsFromBibliographicInfo(request.BibliographicInfo, request.ServiceInfo)
207+
208+
if lookupParams.ServiceType == "" {
209+
return nil
210+
}
211+
212+
cqlList, _, err := lookupQueryBuilder.Build(lookupParams)
213+
if err != nil {
214+
ctx.Logger().Warn("failed build lookup query", "error", err)
215+
return nil
216+
}
217+
218+
lookupCql := strings.Join(cqlList, " or ")
219+
qb, err := cqlbuilder.NewQueryFromString("(" + lookupCql + ")")
220+
if err != nil {
221+
ctx.Logger().Warn("failed to build duplicate check query", "error", err)
222+
return nil
223+
}
224+
formattedTime := time.Now().Add(-time.Duration(*windowHours) * time.Hour).Format(queryTimeFormat)
225+
query, err := qb.And().Search("requester_symbol").Term(requesterSymbol).
226+
And().Search("patron_id").Term(patronId).
227+
And().Search("timestamp").Rel(">=").Term(formattedTime).
228+
And().Search("service_type").Term(lookupParams.ServiceType).
229+
Build()
230+
if err != nil {
231+
ctx.Logger().Warn("failed to build duplicate check query", "error", err)
232+
return nil
233+
}
234+
cql := query.String()
235+
trans, _, err := repo.ListIllTransactions(ctx, ill_db.ListIllTransactionsParams{Limit: 1, Offset: 0}, &cql, []string{requesterSymbol})
236+
if err != nil {
237+
ctx.Logger().Warn("failed to check for duplicate requests, proceeding", "error", err)
238+
return nil // fail open
239+
}
240+
if len(trans) == 0 {
241+
return nil
242+
}
243+
return ErrDuplicateRequest
244+
}
245+
176246
func handleRetryRequest(ctx common.ExtendedContext, request *iso18626.Request, repo ill_db.IllRepo) (string, bool, error) {
177247
// ServiceInfo already nil checked in handleIso18626Request
178248
prevReqId := createPgText(request.ServiceInfo.RequestingAgencyPreviousRequestId)
@@ -268,6 +338,8 @@ func handleRequest(ctx common.ExtendedContext, illMessage *iso18626.ISO18626Mess
268338
handleRequestError(ctx, w, request, iso18626.TypeErrorTypeUnrecognisedDataValue, ReqIdAlreadyExists)
269339
} else if errors.Is(err, ErrRetryNotPossible) {
270340
handleRequestError(ctx, w, request, iso18626.TypeErrorTypeUnrecognisedDataValue, RetryNotPossible)
341+
} else if errors.Is(err, ErrDuplicateRequest) {
342+
handleRequestError(ctx, w, request, iso18626.TypeErrorTypeUnrecognisedDataValue, ReqIsDuplicate)
271343
} else {
272344
ctx.Logger().Error(InternalFailedToSaveTx, "error", err)
273345
http.Error(w, PublicFailedToProcessReqMsg, http.StatusInternalServerError)

broker/handler/iso18626-handler_test.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package handler
33
import (
44
"context"
55
"errors"
6+
"strings"
67
"testing"
78

89
"github.com/indexdata/crosslink/broker/common"
@@ -155,3 +156,211 @@ type MockIllRepositoryNoSelectedSupplier struct {
155156
func (r *MockIllRepositoryNoSelectedSupplier) GetSelectedSupplierForIllTransaction(ctx common.ExtendedContext, illTransId string) (ill_db.LocatedSupplier, error) {
156157
return ill_db.LocatedSupplier{}, pgx.ErrNoRows
157158
}
159+
160+
// mockDuplicateCheckRepo overrides FindDuplicateIllTransaction to return
161+
// configurable results for duplicate-check testing.
162+
type mockDuplicateCheckRepo struct {
163+
mocks.MockIllRepositorySuccess
164+
duplicate bool
165+
err error
166+
called bool
167+
cql string
168+
}
169+
170+
func (r *mockDuplicateCheckRepo) ListIllTransactions(ctx common.ExtendedContext, params ill_db.ListIllTransactionsParams, cql *string, symbols []string) ([]ill_db.IllTransaction, int64, error) {
171+
if cql != nil {
172+
r.cql = *cql
173+
}
174+
r.called = true
175+
if r.duplicate {
176+
return []ill_db.IllTransaction{{ID: "duplicate-id"}}, 1, nil
177+
}
178+
return []ill_db.IllTransaction{}, 0, r.err
179+
}
180+
181+
func TestCheckDuplicateRequest(t *testing.T) {
182+
window1 := 1
183+
window0 := 0
184+
windowNeg := -1
185+
186+
baseRequest := &iso18626.Request{
187+
BibliographicInfo: iso18626.BibliographicInfo{
188+
SupplierUniqueRecordId: "rec-1",
189+
Title: "Test Title",
190+
},
191+
ServiceInfo: &iso18626.ServiceInfo{
192+
ServiceType: iso18626.TypeServiceTypeLoan,
193+
},
194+
PatronInfo: &iso18626.PatronInfo{
195+
PatronId: "patron-1",
196+
},
197+
}
198+
199+
isbnRequest := &iso18626.Request{
200+
BibliographicInfo: iso18626.BibliographicInfo{
201+
BibliographicItemId: []iso18626.BibliographicItemId{
202+
{
203+
BibliographicItemIdentifier: "978-1234",
204+
BibliographicItemIdentifierCode: iso18626.TypeSchemeValuePair{Text: "ISBN"},
205+
},
206+
},
207+
},
208+
ServiceInfo: &iso18626.ServiceInfo{ServiceType: iso18626.TypeServiceTypeCopy},
209+
PatronInfo: &iso18626.PatronInfo{PatronId: "patron-2"},
210+
}
211+
212+
tests := []struct {
213+
name string
214+
request *iso18626.Request
215+
peer ill_db.Peer
216+
duplicate bool
217+
repoErr error
218+
wantErr error
219+
wantRepoCalled bool
220+
wantPatronId string
221+
wantIdentifier string
222+
wantIsbn string
223+
wantIssn string
224+
wantTitle string
225+
wantSvcType string
226+
}{
227+
{
228+
name: "no DuplicateCheckWindowHours configured - skips check",
229+
request: baseRequest,
230+
peer: ill_db.Peer{},
231+
wantErr: nil,
232+
wantRepoCalled: false,
233+
},
234+
{
235+
name: "window is zero - skips check",
236+
request: baseRequest,
237+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window0}},
238+
wantErr: nil,
239+
wantRepoCalled: false,
240+
},
241+
{
242+
name: "window is negative - skips check",
243+
request: baseRequest,
244+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &windowNeg}},
245+
wantErr: nil,
246+
wantRepoCalled: false,
247+
},
248+
{
249+
name: "db error - fails open, allows request through",
250+
request: baseRequest,
251+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
252+
repoErr: errors.New("db connection error"),
253+
wantErr: nil,
254+
wantRepoCalled: true,
255+
wantPatronId: "patron-1",
256+
wantIdentifier: "rec-1",
257+
wantTitle: "Test Title",
258+
wantSvcType: "Loan",
259+
},
260+
{
261+
name: "no duplicate found (ErrNoRows) - not a duplicate",
262+
request: baseRequest,
263+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
264+
repoErr: pgx.ErrNoRows,
265+
wantErr: nil,
266+
wantRepoCalled: true,
267+
wantPatronId: "patron-1",
268+
wantIdentifier: "rec-1",
269+
wantTitle: "Test Title",
270+
wantSvcType: "Loan",
271+
},
272+
{
273+
name: "duplicate found - returns ErrDuplicateRequest",
274+
request: baseRequest,
275+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
276+
duplicate: true,
277+
wantErr: ErrDuplicateRequest,
278+
wantRepoCalled: true,
279+
wantPatronId: "patron-1",
280+
wantIdentifier: "rec-1",
281+
wantTitle: "Test Title",
282+
wantSvcType: "Loan",
283+
},
284+
{
285+
name: "nil PatronInfo - skips duplicate check (can't verify same patron)",
286+
request: &iso18626.Request{
287+
BibliographicInfo: iso18626.BibliographicInfo{SupplierUniqueRecordId: "rec-1"},
288+
ServiceInfo: &iso18626.ServiceInfo{ServiceType: iso18626.TypeServiceTypeLoan},
289+
},
290+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
291+
repoErr: pgx.ErrNoRows,
292+
wantErr: nil,
293+
wantRepoCalled: false,
294+
},
295+
{
296+
name: "no service level - skips duplicate check (can't verify same patron)",
297+
request: &iso18626.Request{
298+
BibliographicInfo: iso18626.BibliographicInfo{
299+
SupplierUniqueRecordId: "rec-1",
300+
Title: "Test Title",
301+
},
302+
PatronInfo: &iso18626.PatronInfo{
303+
PatronId: "patron-1",
304+
},
305+
},
306+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
307+
repoErr: pgx.ErrNoRows,
308+
wantErr: nil,
309+
wantRepoCalled: false,
310+
},
311+
{
312+
name: "isbn passed as parameter to DB query",
313+
request: isbnRequest,
314+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
315+
repoErr: pgx.ErrNoRows,
316+
wantErr: nil,
317+
wantRepoCalled: true,
318+
wantPatronId: "patron-2",
319+
wantIsbn: "978-1234",
320+
wantSvcType: "Copy",
321+
},
322+
{
323+
name: "duplicate found via isbn - returns ErrDuplicateRequest",
324+
request: isbnRequest,
325+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
326+
duplicate: true,
327+
wantErr: ErrDuplicateRequest,
328+
wantRepoCalled: true,
329+
wantPatronId: "patron-2",
330+
wantIsbn: "978-1234",
331+
wantSvcType: "Copy",
332+
},
333+
{
334+
name: "no duplicate - returns nil",
335+
request: isbnRequest,
336+
peer: ill_db.Peer{CustomData: directory.Entry{DuplicateCheckWindowHours: &window1}},
337+
duplicate: false,
338+
wantErr: nil,
339+
wantRepoCalled: true,
340+
wantPatronId: "patron-2",
341+
wantIsbn: "978-1234",
342+
wantSvcType: "Copy",
343+
},
344+
}
345+
346+
for _, tt := range tests {
347+
t.Run(tt.name, func(t *testing.T) {
348+
appCtx := common.CreateExtCtxWithArgs(context.Background(), nil)
349+
mockRepo := &mockDuplicateCheckRepo{
350+
duplicate: tt.duplicate,
351+
err: tt.repoErr,
352+
}
353+
err := checkDuplicateRequest(appCtx, tt.request, mockRepo, "ISIL:REQ1", tt.peer)
354+
assert.Equal(t, tt.wantErr, err)
355+
assert.Equal(t, tt.wantRepoCalled, mockRepo.called)
356+
if tt.wantRepoCalled {
357+
assert.True(t, tt.wantPatronId == "" || strings.Contains(mockRepo.cql, tt.wantPatronId))
358+
assert.True(t, tt.wantIdentifier == "" || strings.Contains(mockRepo.cql, tt.wantIdentifier))
359+
assert.True(t, tt.wantIsbn == "" || strings.Contains(mockRepo.cql, tt.wantIsbn))
360+
assert.True(t, tt.wantIssn == "" || strings.Contains(mockRepo.cql, tt.wantIssn))
361+
assert.True(t, tt.wantTitle == "" || strings.Contains(mockRepo.cql, tt.wantTitle))
362+
assert.True(t, tt.wantSvcType == "" || strings.Contains(mockRepo.cql, tt.wantSvcType))
363+
}
364+
})
365+
}
366+
}

broker/ill_db/ill_cql.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/indexdata/cql-go/cql"
99
"github.com/indexdata/cql-go/cqlbuilder"
1010
"github.com/indexdata/cql-go/pgcql"
11+
pr_db "github.com/indexdata/crosslink/broker/patron_request/db"
1112
)
1213

1314
func handleIllTransactionsQuery(cqlString string, noBaseArgs int) (pgcql.Query, error) {
@@ -28,6 +29,24 @@ func handleIllTransactionsQuery(cqlString string, noBaseArgs int) (pgcql.Query,
2829
f = pgcql.NewFieldString().WithExact()
2930
def.AddField("last_requester_action", f)
3031

32+
def.AddField("isbn", pr_db.NewFieldTextArrayContains("bibliographic_item_identifiers(ill_transaction_data, 'ISBN')").WithFunction("norm_isxn"))
33+
def.AddField("issn", pr_db.NewFieldTextArrayContains("bibliographic_item_identifiers(ill_transaction_data, 'ISSN')").WithFunction("norm_isxn"))
34+
35+
f = pgcql.NewFieldString().WithExact().WithLower().WithColumn("ill_transaction_data->'bibliographicInfo'->>'title'")
36+
def.AddField("title", f)
37+
38+
nf := pgcql.NewFieldDate()
39+
def.AddField("timestamp", nf)
40+
41+
f = pgcql.NewFieldString().WithExact().WithColumn("ill_transaction_data->'patronInfo'->>'patronId'")
42+
def.AddField("patron_id", f)
43+
44+
f = pgcql.NewFieldString().WithExact().WithColumn("ill_transaction_data->'bibliographicInfo'->>'supplierUniqueRecordId'")
45+
def.AddField("supplier_unique_record_id", f)
46+
47+
f = pgcql.NewFieldString().WithExact().WithColumn("ill_transaction_data->'serviceInfo'->>'serviceType'")
48+
def.AddField("service_type", f)
49+
3150
var parser cql.Parser
3251
query, err := parser.Parse(cqlString)
3352
if err != nil {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
DROP INDEX IF EXISTS idx_ill_transaction_requester_timestamp;
2+
3+
DROP INDEX IF EXISTS idx_ill_transaction_isbn;
4+
5+
DROP INDEX IF EXISTS idx_ill_transaction_issn;
6+
7+
DROP INDEX idx_ill_transaction_title_tsv;
8+
9+
DROP INDEX IF EXISTS idx_ill_transaction_supplier_unique_record_id;
10+
11+
DROP INDEX IF EXISTS idx_ill_transaction_patron_id;
12+
13+
DROP INDEX IF EXISTS idx_ill_transaction_service_type;

0 commit comments

Comments
 (0)