diff --git a/.gitignore b/.gitignore index 8b2c8669..207dbdf2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage.out.tmp /.idea go.work go.work.sum +.worktrees/ diff --git a/broker/Makefile b/broker/Makefile index d4d7d68a..9278343f 100644 --- a/broker/Makefile +++ b/broker/Makefile @@ -31,7 +31,8 @@ SQL_GEN_OUT_EVENT = events/event_db_gen.go events/event_models_gen.go events/eve SQL_GEN_OUT_PR = patron_request/db/pr_db_gen.go patron_request/db/pr_models_gen.go patron_request/db/pr_query.sql_gen.go SQL_GEN_OUT_PS = pullslip/db/ps_db_gen.go pullslip/db/ps_models_gen.go pullslip/db/ps_query.sql_gen.go SQL_GEN_OUT_SCHED = scheduler/db/sched_db_gen.go scheduler/db/sched_models_gen.go scheduler/db/sched_query.sql_gen.go -SQL_GEN_OUT = $(SQL_GEN_OUT_ILL_DB) $(SQL_GEN_OUT_EVENT) $(SQL_GEN_OUT_PR) $(SQL_GEN_OUT_PS) $(SQL_GEN_OUT_SCHED) +SQL_GEN_OUT_IMPORT = import/db/import_db_gen.go import/db/import_models_gen.go import/db/import_query.sql_gen.go +SQL_GEN_OUT = $(SQL_GEN_OUT_ILL_DB) $(SQL_GEN_OUT_EVENT) $(SQL_GEN_OUT_PR) $(SQL_GEN_OUT_PS) $(SQL_GEN_OUT_SCHED) $(SQL_GEN_OUT_IMPORT) SQL_GEN_IN = sqlc/*.sql # OpenAPI @@ -61,6 +62,12 @@ SCHED_OAPI_CFG = $(SCHED_OAPI_DIR)/sched-cfg.yaml SCHED_OAPI_SPEC = $(SCHED_OAPI_DIR)/open-api.yaml SCHED_OAPI_GEN = scheduler/oapi/sched_openapi_gen.go +# Import OpenAPI +IMPORT_OAPI_DIR=oapi +IMPORT_OAPI_CFG = $(IMPORT_OAPI_DIR)/import-cfg.yaml +IMPORT_OAPI_SPEC = $(IMPORT_OAPI_DIR)/open-api.yaml +IMPORT_OAPI_GEN = import/oapi/import_openapi_gen.go + .PHONY: all docker generate generate-sqlc generate-api generate-commit-id check run fmt fmt-check clean clean-build-caches view-coverage deps-update tools-update lint vulncheck check-coverage all: $(BINARY) archive @@ -74,7 +81,7 @@ generate-commit-id: $(COMMIT_ID) generate-sqlc: $(SQL_GEN_OUT) -generate-api: $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) +generate-api: $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) $(IMPORT_OAPI_GEN) $(STATE_MODELS_JSON): $(STATE_MODELS_YAML) mkdir -p $(@D) @@ -93,16 +100,19 @@ $(PS_OAPI_GEN): $(PS_OAPI_CFG) $(PS_OAPI_SPEC) $(SCHED_OAPI_GEN): $(SCHED_OAPI_CFG) $(SCHED_OAPI_SPEC) $(OAPI_CODEGEN) -config ./$(SCHED_OAPI_CFG) ./$(SCHED_OAPI_SPEC) +$(IMPORT_OAPI_GEN): $(IMPORT_OAPI_CFG) $(IMPORT_OAPI_SPEC) $(OAPI_OVERLAY) + $(OAPI_CODEGEN) -config ./$(IMPORT_OAPI_CFG) ./$(IMPORT_OAPI_SPEC) + $(SQL_GEN_OUT): $(SQL_GEN_IN) $(SQLC_CONFIG) $(SQLC) generate -f $(SQLC_CONFIG) $(COMMIT_ID): $(GIT_COMMIT_DEPS) commit_id="$$( $(GIT) rev-parse --short HEAD )" && printf '%s' "$$commit_id" > $(COMMIT_ID) -$(BINARY): $(COMMIT_ID) $(SQL_GEN_OUT) $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) $(BUILD_GOFILES) $(STATE_MODELS_JSON) +$(BINARY): $(COMMIT_ID) $(SQL_GEN_OUT) $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) $(IMPORT_OAPI_GEN) $(BUILD_GOFILES) $(STATE_MODELS_JSON) $(GO) build -v -o $(BINARY) ./$(MAIN_PACKAGE) -archive: $(COMMIT_ID) $(SQL_GEN_OUT) $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) $(BUILD_GOFILES) $(STATE_MODELS_JSON) +archive: $(COMMIT_ID) $(SQL_GEN_OUT) $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) $(IMPORT_OAPI_GEN) $(BUILD_GOFILES) $(STATE_MODELS_JSON) $(GO) build -v -o archive ./cmd/archive check: generate @@ -149,6 +159,7 @@ clean: rm -f $(PR_OAPI_GEN) rm -f $(PS_OAPI_GEN) rm -f $(SCHED_OAPI_GEN) + rm -f $(IMPORT_OAPI_GEN) clean-build-caches: $(GO) clean -cache diff --git a/broker/app/app.go b/broker/app/app.go index 854a0ca7..6e9b204d 100644 --- a/broker/app/app.go +++ b/broker/app/app.go @@ -16,6 +16,9 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/indexdata/crosslink/broker/catalog" "github.com/indexdata/crosslink/broker/email" + importapi "github.com/indexdata/crosslink/broker/import/api" + importdb "github.com/indexdata/crosslink/broker/import/db" + importoapi "github.com/indexdata/crosslink/broker/import/oapi" prapi "github.com/indexdata/crosslink/broker/patron_request/api" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" "github.com/indexdata/crosslink/broker/patron_request/proapi" @@ -99,17 +102,18 @@ var ServeMux *http.ServeMux var appCtx = common.CreateExtCtxWithLogArgsAndHandler(context.Background(), nil, configLog()) type Context struct { - EventBus events.EventBus - IllRepo ill_db.IllRepo - EventRepo events.EventRepo - DirAdapter adapter.DirectoryLookupAdapter - PrRepo pr_db.PrRepo - TenantResolver *tenant.TenantResolver - ApiHandler api.ApiHandler - PrApiHandler prapi.PatronRequestApiHandler - SseBroker *api.SseBroker - PsApiHandler psapi.PullSlipApiHandler - SchedApiHandler schedapi.SchedulerApiHandler + EventBus events.EventBus + IllRepo ill_db.IllRepo + EventRepo events.EventRepo + DirAdapter adapter.DirectoryLookupAdapter + PrRepo pr_db.PrRepo + TenantResolver *tenant.TenantResolver + ApiHandler api.ApiHandler + PrApiHandler prapi.PatronRequestApiHandler + SseBroker *api.SseBroker + PsApiHandler psapi.PullSlipApiHandler + SchedApiHandler schedapi.SchedulerApiHandler + ImportApiHandler importapi.ApiHandler } func configLog() slog.Handler { @@ -221,18 +225,21 @@ func Init(ctx context.Context) (Context, error) { return Context{}, err } + importRepo := importdb.CreateImportRepo(pool) + importApiHandler := importapi.NewApiHandler(importRepo, illRepo, dirAdapter, &prservice.StateModelService{}) return Context{ - EventBus: eventBus, - IllRepo: illRepo, - EventRepo: eventRepo, - DirAdapter: dirAdapter, - PrRepo: prRepo, - TenantResolver: tenantResolver, - ApiHandler: apiHandler, - PrApiHandler: prApiHandler, - SseBroker: sseBroker, - PsApiHandler: psApiHandler, - SchedApiHandler: schedApiHandler, + EventBus: eventBus, + IllRepo: illRepo, + EventRepo: eventRepo, + DirAdapter: dirAdapter, + PrRepo: prRepo, + TenantResolver: tenantResolver, + ApiHandler: apiHandler, + PrApiHandler: prApiHandler, + SseBroker: sseBroker, + PsApiHandler: psApiHandler, + SchedApiHandler: schedApiHandler, + ImportApiHandler: importApiHandler, }, nil } @@ -267,6 +274,7 @@ func StartServer(ctx Context) error { }) psoapi.HandlerFromMux(&ctx.PsApiHandler, ServeMux) schedoapi.HandlerFromMux(&ctx.SchedApiHandler, ServeMux) + importoapi.HandlerFromMux(&ctx.ImportApiHandler, ServeMux) ServeMux.HandleFunc("GET /sse/events", ctx.SseBroker.ServeHTTP) if ctx.TenantResolver.HasTenantMapping() { basePath := tenant.OKAPI_PATH_PREFIX diff --git a/broker/import/api/api_handler.go b/broker/import/api/api_handler.go new file mode 100644 index 00000000..b7407405 --- /dev/null +++ b/broker/import/api/api_handler.go @@ -0,0 +1,81 @@ +package importapi + +import ( + "errors" + "mime" + "net/http" + + "github.com/indexdata/crosslink/broker/adapter" + brokerapi "github.com/indexdata/crosslink/broker/api" + "github.com/indexdata/crosslink/broker/common" + "github.com/indexdata/crosslink/broker/ill_db" + importdb "github.com/indexdata/crosslink/broker/import/db" + importoapi "github.com/indexdata/crosslink/broker/import/oapi" + "github.com/indexdata/crosslink/broker/import/service" + prservice "github.com/indexdata/crosslink/broker/patron_request/service" +) + +type ApiHandler struct { + importer service.Importer + maxImportBodyBytes int64 +} + +const maxImportBodyBytes int64 = 2 << 30 // 2 GB + +var _ importoapi.ServerInterface = (*ApiHandler)(nil) + +func NewApiHandler(repo importdb.ImportRepo, illRepo ill_db.IllRepo, directoryAdapter adapter.DirectoryLookupAdapter, stateModels *prservice.StateModelService) ApiHandler { + return ApiHandler{ + importer: service.NewImporter(repo, illRepo, directoryAdapter, stateModels, nil), + } +} + +func (a *ApiHandler) PostImport(w http.ResponseWriter, r *http.Request, params importoapi.PostImportParams) { + ctx := common.CreateExtCtxWithArgs(r.Context(), &common.LoggerArgs{ + Other: map[string]string{"method": "PostImport"}, + }) + policyValue := "" + if params.ConflictPolicy != nil { + policyValue = string(*params.ConflictPolicy) + } + policy, err := importdb.ParseConflictPolicy(policyValue) + if err != nil { + brokerapi.AddBadRequestError(ctx, w, err) + return + } + if r.Body == nil || r.Body == http.NoBody { + brokerapi.AddBadRequestError(ctx, w, errors.New("body is required")) + return + } + if !isNDJSONContentType(r.Header.Get("Content-Type")) { + brokerapi.AddBadRequestError(ctx, w, errors.New("content type must be application/x-ndjson")) + return + } + + bodyLimit := a.maxImportBodyBytes + if bodyLimit <= 0 { + bodyLimit = maxImportBodyBytes + } + if r.ContentLength > bodyLimit { + http.Error(w, "import request too large", http.StatusRequestEntityTooLarge) + return + } + r.Body = http.MaxBytesReader(w, r.Body, bodyLimit) + result, err := a.importer.Import(ctx, policy, r.Body) + if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) || errors.Is(err, service.ErrImportRecordTooLarge) { + http.Error(w, "import request too large", http.StatusRequestEntityTooLarge) + return + } + ctx.Logger().Error("failed to read import request", "error", err) + http.Error(w, "failed to read import request", http.StatusInternalServerError) + return + } + brokerapi.WriteJsonResponse(w, result) +} + +func isNDJSONContentType(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + return err == nil && mediaType == "application/x-ndjson" +} diff --git a/broker/import/api/api_handler_test.go b/broker/import/api/api_handler_test.go new file mode 100644 index 00000000..ee2b18c8 --- /dev/null +++ b/broker/import/api/api_handler_test.go @@ -0,0 +1,182 @@ +package importapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/indexdata/crosslink/broker/adapter" + "github.com/indexdata/crosslink/broker/common" + "github.com/indexdata/crosslink/broker/ill_db" + importdb "github.com/indexdata/crosslink/broker/import/db" + importoapi "github.com/indexdata/crosslink/broker/import/oapi" + "github.com/indexdata/crosslink/broker/import/service" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" + "github.com/stretchr/testify/assert" +) + +var cache = &recordingPeerCache{peers: []ill_db.Peer{{ID: "peer-requester"}, {ID: "peer-supplier"}}} + +func fixedClock() time.Time { return time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) } + +func TestPostImportDefaultsConflictPolicyToFail(t *testing.T) { + repo := &recordingImportRepo{} + handler := ApiHandler{importer: service.NewImporter(repo, cache, nil, nil, fixedClock)} + recorder := httptest.NewRecorder() + handler.PostImport(recorder, ndjsonRequest(`{"type":"template","owner":"ISIL:OWNER","data":`+string(validTemplateData())+`}`+"\n"), importoapi.PostImportParams{}) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, importdb.ConflictPolicyFail, repo.templatePolicy) + assert.JSONEq(t, `{"patronRequests":{"imported":0,"failed":0,"skipped":0},"batchActions":{"imported":0,"failed":0,"skipped":0},"templates":{"imported":1,"failed":0,"skipped":0},"errors":[]}`, recorder.Body.String()) +} + +func TestPostImportAcceptsExplicitConflictPolicy(t *testing.T) { + repo := &recordingImportRepo{} + handler := ApiHandler{importer: service.NewImporter(repo, cache, nil, nil, fixedClock)} + policy := importoapi.Update + recorder := httptest.NewRecorder() + handler.PostImport(recorder, ndjsonRequest(`{"type":"template","owner":"ISIL:OWNER","data":`+string(validTemplateData())+`}`+"\n"), importoapi.PostImportParams{ConflictPolicy: &policy}) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Equal(t, importdb.ConflictPolicyUpdate, repo.templatePolicy) +} + +func TestPostImportRejectsUnknownConflictPolicyBeforeBodyValidation(t *testing.T) { + handler := ApiHandler{importer: service.NewImporter(&recordingImportRepo{}, cache, nil, nil, fixedClock)} + policy := importoapi.ConflictPolicy("unknown") + recorder := httptest.NewRecorder() + handler.PostImport(recorder, httptest.NewRequest(http.MethodPost, "/import", http.NoBody), importoapi.PostImportParams{ConflictPolicy: &policy}) + + assert.Equal(t, http.StatusBadRequest, recorder.Code) + assert.Contains(t, recorder.Body.String(), "unknown conflict policy") +} + +func TestPostImportValidatesBodyAndContentType(t *testing.T) { + handler := ApiHandler{importer: service.NewImporter(&recordingImportRepo{}, cache, nil, nil, fixedClock)} + + missingBody := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/import", http.NoBody) + req.Header.Set("Content-Type", "application/x-ndjson") + handler.PostImport(missingBody, req, importoapi.PostImportParams{}) + assert.Equal(t, http.StatusBadRequest, missingBody.Code) + + wrongType := httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/import", strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + handler.PostImport(wrongType, req, importoapi.PostImportParams{}) + assert.Equal(t, http.StatusBadRequest, wrongType.Code) +} + +func TestPostImportRejectsKnownOversizedBody(t *testing.T) { + handler := ApiHandler{ + importer: service.NewImporter(&recordingImportRepo{}, cache, nil, nil, fixedClock), + maxImportBodyBytes: 128, + } + req := ndjsonRequest(strings.Repeat("x", 129)) + recorder := httptest.NewRecorder() + + handler.PostImport(recorder, req, importoapi.PostImportParams{}) + + assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) +} + +func TestPostImportRejectsChunkedOversizedBody(t *testing.T) { + repo := &recordingImportRepo{} + handler := ApiHandler{ + importer: service.NewImporter(repo, cache, nil, nil, fixedClock), + maxImportBodyBytes: 128, + } + req := ndjsonRequest(strings.Repeat("x", 129)) + req.ContentLength = -1 + recorder := httptest.NewRecorder() + + handler.PostImport(recorder, req, importoapi.PostImportParams{}) + + assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) + assert.Zero(t, repo.patronCalls) + assert.Zero(t, repo.templateCalls) +} + +func TestPostImportRejectsOversizedRecord(t *testing.T) { + repo := &recordingImportRepo{} + handler := ApiHandler{importer: service.NewImporter(repo, cache, nil, nil, fixedClock)} + req := ndjsonRequest(strings.Repeat("x", (1<<20)+1) + "\n") + recorder := httptest.NewRecorder() + + handler.PostImport(recorder, req, importoapi.PostImportParams{}) + + assert.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code) + assert.Zero(t, repo.patronCalls) + assert.Zero(t, repo.templateCalls) +} + +func ndjsonRequest(body string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/import", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-ndjson") + return req +} + +type recordingImportRepo struct { + patron importdb.PatronRequestBundle + patronPolicy importdb.ConflictPolicy + patronResult importdb.Result + patronErr error + patronCalls int + template pr_db.SaveTemplateParams + templatePolicy importdb.ConflictPolicy + templateResults []importdb.Result + templateErrors []error + templateCalls int + batch sched_db.SaveScheduledTaskParams + batchPolicy importdb.ConflictPolicy + batchResult importdb.Result + batchErr error +} + +func (r *recordingImportRepo) WithTxFunc(_ common.ExtendedContext, fn func(importdb.ImportRepo) error) error { + return fn(r) +} + +func (r *recordingImportRepo) ImportPatronRequest(_ common.ExtendedContext, bundle importdb.PatronRequestBundle, policy importdb.ConflictPolicy) (importdb.Result, error) { + r.patron, r.patronPolicy, r.patronCalls = bundle, policy, r.patronCalls+1 + return r.patronResult, r.patronErr +} +func (r *recordingImportRepo) ImportTemplate(_ common.ExtendedContext, params pr_db.SaveTemplateParams, policy importdb.ConflictPolicy) (importdb.Result, error) { + r.template, r.templatePolicy, r.templateCalls = params, policy, r.templateCalls+1 + index := r.templateCalls - 1 + var result importdb.Result + if index < len(r.templateResults) { + result = r.templateResults[index] + } else { + result = importdb.Result{Outcome: importdb.OutcomeImported} + } + if index < len(r.templateErrors) { + return result, r.templateErrors[index] + } + return result, nil +} +func (r *recordingImportRepo) ImportBatchAction(_ common.ExtendedContext, params sched_db.SaveScheduledTaskParams, policy importdb.ConflictPolicy) (importdb.Result, error) { + r.batch, r.batchPolicy = params, policy + return r.batchResult, r.batchErr +} +func validTemplateData() json.RawMessage { + return json.RawMessage(`{"title":"Title","purpose":"email","body":"Body","contentType":"text","labels":["first"],"audience":"patron"}`) +} + +type recordingPeerCache struct { + ill_db.PgIllRepo + symbols []string + peers []ill_db.Peer + err error + calls int +} + +func (c *recordingPeerCache) GetCachedPeersBySymbols(_ common.ExtendedContext, symbols []string, _ adapter.DirectoryLookupAdapter) ([]ill_db.Peer, string, error) { + c.calls++ + c.symbols = append([]string(nil), symbols...) + return c.peers, "test", c.err +} diff --git a/broker/import/db/import_db_gen.go b/broker/import/db/import_db_gen.go new file mode 100644 index 00000000..bf043c26 --- /dev/null +++ b/broker/import/db/import_db_gen.go @@ -0,0 +1,25 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importdb + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New() *Queries { + return &Queries{} +} + +type Queries struct { +} diff --git a/broker/import/db/import_models_gen.go b/broker/import/db/import_models_gen.go new file mode 100644 index 00000000..8bc586ff --- /dev/null +++ b/broker/import/db/import_models_gen.go @@ -0,0 +1,187 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package importdb + +import ( + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/iso18626" + "github.com/jackc/pgx/v5/pgtype" +) + +type BranchSymbol struct { + SymbolValue string + PeerID string +} + +type IllTransaction struct { + ID string + Timestamp pgtype.Timestamp + RequesterSymbol pgtype.Text + RequesterID pgtype.Text + LastRequesterAction pgtype.Text + PrevRequesterAction pgtype.Text + SupplierSymbol pgtype.Text + RequesterRequestID pgtype.Text + PrevRequesterRequestID pgtype.Text + SupplierRequestID pgtype.Text + LastSupplierStatus pgtype.Text + PrevSupplierStatus pgtype.Text + IllTransactionData []byte +} + +type Item struct { + ID string + PrID string + Barcode string + CallNumber pgtype.Text + Title pgtype.Text + ItemID pgtype.Text + LmsRequestID pgtype.Text + CreatedAt pgtype.Timestamp +} + +type LocatedSupplier struct { + ID string + IllTransactionID string + SupplierID string + SupplierSymbol string + Ordinal int32 + SupplierStatus pgtype.Text + PrevAction pgtype.Text + PrevStatus pgtype.Text + LastAction pgtype.Text + LastStatus pgtype.Text + LocalID pgtype.Text + PrevReason pgtype.Text + LastReason pgtype.Text + SupplierRequestID pgtype.Text + LocalSupplier bool +} + +type Notification struct { + ID string + PrID string + FromSymbol string + ToSymbol string + Direction string + Kind string + Note pgtype.Text + Cost pgtype.Numeric + Currency pgtype.Text + Condition pgtype.Text + Receipt pgtype.Text + CreatedAt pgtype.Timestamp + AcknowledgedAt pgtype.Timestamp +} + +type PatronRequest struct { + ID string + CreatedAt pgtype.Timestamp + IllRequest iso18626.Request + State pr_db.PatronRequestState + Side pr_db.PatronRequestSide + Patron pgtype.Text + RequesterSymbol pgtype.Text + SupplierSymbol pgtype.Text + Tenant pgtype.Text + RequesterReqID pgtype.Text + NeedsAttention bool + LastAction pgtype.Text + LastActionOutcome pgtype.Text + LastActionResult pgtype.Text + Items []pr_db.PrItem + Language interface{} + TerminalState bool + UpdatedAt pgtype.Timestamp + IllResponse iso18626.SupplyingAgencyMessage + InternalNote pgtype.Text + NextReqID pgtype.Text + PrevReqID pgtype.Text + RetryBibInfo *iso18626.BibliographicInfo + StateModel string +} + +type PatronRequestSearchView struct { + ID string + CreatedAt pgtype.Timestamp + IllRequest []byte + State string + Side string + Patron pgtype.Text + RequesterSymbol pgtype.Text + SupplierSymbol pgtype.Text + Tenant pgtype.Text + RequesterReqID pgtype.Text + NeedsAttention bool + LastAction pgtype.Text + LastActionOutcome pgtype.Text + LastActionResult pgtype.Text + Items []byte + Language interface{} + TerminalState bool + UpdatedAt pgtype.Timestamp + IllResponse []byte + InternalNote pgtype.Text + NextReqID pgtype.Text + PrevReqID pgtype.Text + RetryBibInfo []byte + StateModel string + HasNotification bool + HasCost bool + HasUnreadNotification bool + HasInternalNote pgtype.Bool + ServiceType interface{} + ServiceLevel interface{} + NeededAt pgtype.Timestamp + UnreadNotificationsCount int64 + RequesterName pgtype.Text + SupplierName pgtype.Text +} + +type Peer struct { + ID string + Name string + RefreshPolicy string + RefreshTime pgtype.Timestamp + Url string + LoansCount int32 + BorrowsCount int32 + Vendor string + BrokerMode string + CustomData []byte + HttpHeaders []byte +} + +type ScheduledTask struct { + ID string + EventName string + Schedule string + ActionData []byte + Title pgtype.Text + RunAt pgtype.Timestamptz + Status string + Owner string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Symbol struct { + SymbolValue string + PeerID string +} + +type Template struct { + ID string + Owner string + Title string + Purpose string + Subject pgtype.Text + Body string + ContentType string + Labels []string + Audience pgtype.Text + CreatedAt pgtype.Timestamp + UpdatedAt pgtype.Timestamp +} diff --git a/broker/import/db/import_query.sql_gen.go b/broker/import/db/import_query.sql_gen.go new file mode 100644 index 00000000..dbd9b46d --- /dev/null +++ b/broker/import/db/import_query.sql_gen.go @@ -0,0 +1,300 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: import_query.sql + +package importdb + +import ( + "context" + + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/iso18626" + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteImportedItemsNotPresent = `-- name: DeleteImportedItemsNotPresent :exec +DELETE FROM item +WHERE pr_id = $1 + AND id <> ALL($2::text[]) +` + +type DeleteImportedItemsNotPresentParams struct { + PrID string + Ids []string +} + +func (q *Queries) DeleteImportedItemsNotPresent(ctx context.Context, db DBTX, arg DeleteImportedItemsNotPresentParams) error { + _, err := db.Exec(ctx, deleteImportedItemsNotPresent, arg.PrID, arg.Ids) + return err +} + +const deleteImportedLocatedSuppliersNotPresent = `-- name: DeleteImportedLocatedSuppliersNotPresent :exec +DELETE FROM located_supplier +WHERE ill_transaction_id = $1 + AND id <> ALL($2::text[]) +` + +type DeleteImportedLocatedSuppliersNotPresentParams struct { + IllTransactionID string + Ids []string +} + +func (q *Queries) DeleteImportedLocatedSuppliersNotPresent(ctx context.Context, db DBTX, arg DeleteImportedLocatedSuppliersNotPresentParams) error { + _, err := db.Exec(ctx, deleteImportedLocatedSuppliersNotPresent, arg.IllTransactionID, arg.Ids) + return err +} + +const deleteImportedNotificationsNotPresent = `-- name: DeleteImportedNotificationsNotPresent :exec +DELETE FROM notification +WHERE pr_id = $1 + AND id <> ALL($2::text[]) +` + +type DeleteImportedNotificationsNotPresentParams struct { + PrID string + Ids []string +} + +func (q *Queries) DeleteImportedNotificationsNotPresent(ctx context.Context, db DBTX, arg DeleteImportedNotificationsNotPresentParams) error { + _, err := db.Exec(ctx, deleteImportedNotificationsNotPresent, arg.PrID, arg.Ids) + return err +} + +const getImportIllTransactionByRequesterRequestID = `-- name: GetImportIllTransactionByRequesterRequestID :one +SELECT id, requester_request_id +FROM ill_transaction +WHERE requester_request_id = $1 +` + +type GetImportIllTransactionByRequesterRequestIDRow struct { + ID string + RequesterRequestID pgtype.Text +} + +func (q *Queries) GetImportIllTransactionByRequesterRequestID(ctx context.Context, db DBTX, requesterRequestID pgtype.Text) (GetImportIllTransactionByRequesterRequestIDRow, error) { + row := db.QueryRow(ctx, getImportIllTransactionByRequesterRequestID, requesterRequestID) + var i GetImportIllTransactionByRequesterRequestIDRow + err := row.Scan(&i.ID, &i.RequesterRequestID) + return i, err +} + +const getImportItemParent = `-- name: GetImportItemParent :one +SELECT pr_id FROM item WHERE id = $1 +` + +func (q *Queries) GetImportItemParent(ctx context.Context, db DBTX, id string) (string, error) { + row := db.QueryRow(ctx, getImportItemParent, id) + var pr_id string + err := row.Scan(&pr_id) + return pr_id, err +} + +const getImportLocatedSupplierParent = `-- name: GetImportLocatedSupplierParent :one +SELECT ill_transaction_id FROM located_supplier WHERE id = $1 +` + +func (q *Queries) GetImportLocatedSupplierParent(ctx context.Context, db DBTX, id string) (string, error) { + row := db.QueryRow(ctx, getImportLocatedSupplierParent, id) + var ill_transaction_id string + err := row.Scan(&ill_transaction_id) + return ill_transaction_id, err +} + +const getImportNotificationParent = `-- name: GetImportNotificationParent :one +SELECT pr_id FROM notification WHERE id = $1 +` + +func (q *Queries) GetImportNotificationParent(ctx context.Context, db DBTX, id string) (string, error) { + row := db.QueryRow(ctx, getImportNotificationParent, id) + var pr_id string + err := row.Scan(&pr_id) + return pr_id, err +} + +const lockImportBatchAction = `-- name: LockImportBatchAction :one +SELECT id, created_at +FROM scheduled_task +WHERE owner = $1 + AND title = $2 +FOR UPDATE +` + +type LockImportBatchActionParams struct { + Owner string + Title pgtype.Text +} + +type LockImportBatchActionRow struct { + ID string + CreatedAt pgtype.Timestamptz +} + +func (q *Queries) LockImportBatchAction(ctx context.Context, db DBTX, arg LockImportBatchActionParams) (LockImportBatchActionRow, error) { + row := db.QueryRow(ctx, lockImportBatchAction, arg.Owner, arg.Title) + var i LockImportBatchActionRow + err := row.Scan(&i.ID, &i.CreatedAt) + return i, err +} + +const lockImportIllTransaction = `-- name: LockImportIllTransaction :one +SELECT id, requester_request_id +FROM ill_transaction +WHERE id = $1 +FOR UPDATE +` + +type LockImportIllTransactionRow struct { + ID string + RequesterRequestID pgtype.Text +} + +func (q *Queries) LockImportIllTransaction(ctx context.Context, db DBTX, id string) (LockImportIllTransactionRow, error) { + row := db.QueryRow(ctx, lockImportIllTransaction, id) + var i LockImportIllTransactionRow + err := row.Scan(&i.ID, &i.RequesterRequestID) + return i, err +} + +const lockImportPatronRequest = `-- name: LockImportPatronRequest :one +SELECT id, requester_req_id +FROM patron_request +WHERE id = $1 +FOR UPDATE +` + +type LockImportPatronRequestRow struct { + ID string + RequesterReqID pgtype.Text +} + +func (q *Queries) LockImportPatronRequest(ctx context.Context, db DBTX, id string) (LockImportPatronRequestRow, error) { + row := db.QueryRow(ctx, lockImportPatronRequest, id) + var i LockImportPatronRequestRow + err := row.Scan(&i.ID, &i.RequesterReqID) + return i, err +} + +const lockImportTemplatesByLabels = `-- name: LockImportTemplatesByLabels :many +SELECT id, created_at +FROM template +WHERE owner = $1 + AND labels && $2::text[] +ORDER BY id +FOR UPDATE +` + +type LockImportTemplatesByLabelsParams struct { + Owner string + Labels []string +} + +type LockImportTemplatesByLabelsRow struct { + ID string + CreatedAt pgtype.Timestamp +} + +func (q *Queries) LockImportTemplatesByLabels(ctx context.Context, db DBTX, arg LockImportTemplatesByLabelsParams) ([]LockImportTemplatesByLabelsRow, error) { + rows, err := db.Query(ctx, lockImportTemplatesByLabels, arg.Owner, arg.Labels) + if err != nil { + return nil, err + } + defer rows.Close() + var items []LockImportTemplatesByLabelsRow + for rows.Next() { + var i LockImportTemplatesByLabelsRow + if err := rows.Scan(&i.ID, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateImportedPatronRequest = `-- name: UpdateImportedPatronRequest :exec +UPDATE patron_request +SET created_at = $1, + ill_request = $2, + state = $3, + side = $4, + patron = $5, + requester_symbol = $6, + supplier_symbol = $7, + tenant = $8, + requester_req_id = $9, + needs_attention = $10, + last_action = $11, + last_action_outcome = $12, + last_action_result = $13, + items = $14, + language = $15, + terminal_state = $16, + updated_at = $17, + ill_response = $18, + internal_note = $19, + next_req_id = $20, + prev_req_id = $21, + retry_bib_info = $22, + state_model = $23 +WHERE id = $24 +` + +type UpdateImportedPatronRequestParams struct { + CreatedAt pgtype.Timestamp + IllRequest iso18626.Request + State pr_db.PatronRequestState + Side pr_db.PatronRequestSide + Patron pgtype.Text + RequesterSymbol pgtype.Text + SupplierSymbol pgtype.Text + Tenant pgtype.Text + RequesterReqID pgtype.Text + NeedsAttention bool + LastAction pgtype.Text + LastActionOutcome pgtype.Text + LastActionResult pgtype.Text + Items []pr_db.PrItem + Language interface{} + TerminalState bool + UpdatedAt pgtype.Timestamp + IllResponse iso18626.SupplyingAgencyMessage + InternalNote pgtype.Text + NextReqID pgtype.Text + PrevReqID pgtype.Text + RetryBibInfo *iso18626.BibliographicInfo + StateModel string + ID string +} + +func (q *Queries) UpdateImportedPatronRequest(ctx context.Context, db DBTX, arg UpdateImportedPatronRequestParams) error { + _, err := db.Exec(ctx, updateImportedPatronRequest, + arg.CreatedAt, + arg.IllRequest, + arg.State, + arg.Side, + arg.Patron, + arg.RequesterSymbol, + arg.SupplierSymbol, + arg.Tenant, + arg.RequesterReqID, + arg.NeedsAttention, + arg.LastAction, + arg.LastActionOutcome, + arg.LastActionResult, + arg.Items, + arg.Language, + arg.TerminalState, + arg.UpdatedAt, + arg.IllResponse, + arg.InternalNote, + arg.NextReqID, + arg.PrevReqID, + arg.RetryBibInfo, + arg.StateModel, + arg.ID, + ) + return err +} diff --git a/broker/import/db/models.go b/broker/import/db/models.go new file mode 100644 index 00000000..4b3f16c9 --- /dev/null +++ b/broker/import/db/models.go @@ -0,0 +1,81 @@ +package importdb + +import ( + "fmt" + + "github.com/indexdata/crosslink/broker/common" + ill_db "github.com/indexdata/crosslink/broker/ill_db" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/broker/repo" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" +) + +type ConflictPolicy string + +const ( + ConflictPolicyFail ConflictPolicy = "fail" + ConflictPolicySkip ConflictPolicy = "skip" + ConflictPolicyUpdate ConflictPolicy = "update" +) + +func ParseConflictPolicy(value string) (ConflictPolicy, error) { + switch ConflictPolicy(value) { + case "", ConflictPolicyFail: + return ConflictPolicyFail, nil + case ConflictPolicySkip: + return ConflictPolicySkip, nil + case ConflictPolicyUpdate: + return ConflictPolicyUpdate, nil + default: + return "", fmt.Errorf("unknown conflict policy: %s", value) + } +} + +func (p ConflictPolicy) validate() error { + switch p { + case ConflictPolicyFail, ConflictPolicySkip, ConflictPolicyUpdate: + return nil + default: + return fmt.Errorf("unsupported conflict policy %q", p) + } +} + +type Outcome string + +const ( + OutcomeImported Outcome = "imported" + OutcomeSkipped Outcome = "skipped" +) + +type Result struct { + Outcome Outcome + Diagnostic string +} + +type PatronRequestBundle struct { + PatronRequest pr_db.CreatePatronRequestParams + Items []pr_db.SaveItemParams + Notifications []pr_db.SaveNotificationParams + IllTransaction *ill_db.SaveIllTransactionParams + LocatedSuppliers []ill_db.SaveLocatedSupplierParams +} + +type ImportRepo interface { + repo.Transactional[ImportRepo] + ImportPatronRequest(common.ExtendedContext, PatronRequestBundle, ConflictPolicy) (Result, error) + ImportTemplate(common.ExtendedContext, pr_db.SaveTemplateParams, ConflictPolicy) (Result, error) + ImportBatchAction(common.ExtendedContext, sched_db.SaveScheduledTaskParams, ConflictPolicy) (Result, error) +} + +type ConflictError struct { + Resource string + Identifier string + Reason string +} + +func (e *ConflictError) Error() string { + if e.Reason == "" { + return fmt.Sprintf("%s %q already exists", e.Resource, e.Identifier) + } + return fmt.Sprintf("%s %q conflicts: %s", e.Resource, e.Identifier, e.Reason) +} diff --git a/broker/import/db/patron_request_test.go b/broker/import/db/patron_request_test.go new file mode 100644 index 00000000..2f43f305 --- /dev/null +++ b/broker/import/db/patron_request_test.go @@ -0,0 +1,173 @@ +package importdb + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/indexdata/crosslink/broker/common" + "github.com/indexdata/crosslink/broker/dbutil" + ill_db "github.com/indexdata/crosslink/broker/ill_db" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + test "github.com/indexdata/crosslink/broker/test/utils" + "github.com/indexdata/crosslink/iso18626" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + importTestPool *pgxpool.Pool + importTestRepo ImportRepo + importTestCtx = common.CreateExtCtxWithArgs(context.Background(), nil) +) + +func TestMain(m *testing.M) { + ctx, container, connectionString, err := test.StartPGContainer() + if err != nil { + panic(err) + } + connectionString += dbutil.SearchPath("crosslink_broker") + if err = dbutil.RunDbProvision(connectionString, "crosslink_broker"); err != nil { + panic(err) + } + if importTestPool, err = dbutil.InitDbPool(connectionString); err != nil { + panic(err) + } + for _, schemaFile := range []string{"../../sqlc/ill_schema.sql", "../../sqlc/pr_schema.sql", "../../sqlc/event_schema.sql", "../../sqlc/sched_schema.sql"} { + schema, readErr := os.ReadFile(schemaFile) + if readErr != nil { + panic(readErr) + } + if _, execErr := importTestPool.Exec(context.Background(), string(schema)); execErr != nil { + panic(execErr) + } + } + importTestRepo = CreateImportRepo(importTestPool) + code := m.Run() + importTestPool.Close() + if err := test.TerminatePGContainer(ctx, container); err != nil { + panic(err) + } + os.Exit(code) +} + +func TestImportPatronRequestInsertsAndSynchronizesCompleteBundle(t *testing.T) { + prefix := uuid.NewString() + requestID := prefix + "-request" + bundle := testPatronBundle(prefix, requestID) + inserted, err := importTestRepo.ImportPatronRequest(importTestCtx, bundle, ConflictPolicyFail) + require.NoError(t, err) + assert.Equal(t, OutcomeImported, inserted.Outcome) + + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM patron_request WHERE id=$1", bundle.PatronRequest.ID)) + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM item WHERE pr_id=$1", bundle.PatronRequest.ID)) + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM notification WHERE pr_id=$1", bundle.PatronRequest.ID)) + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM ill_transaction WHERE id=$1", bundle.IllTransaction.ID)) + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM located_supplier WHERE ill_transaction_id=$1", bundle.IllTransaction.ID)) + assert.Equal(t, 0, queryCount(t, "SELECT count(*) FROM event WHERE patron_request_id=$1", bundle.PatronRequest.ID)) + + failResult, err := importTestRepo.ImportPatronRequest(importTestCtx, bundle, ConflictPolicyFail) + assert.Empty(t, failResult.Outcome) + var conflict *ConflictError + assert.ErrorAs(t, err, &conflict) + + skipped, err := importTestRepo.ImportPatronRequest(importTestCtx, bundle, ConflictPolicySkip) + require.NoError(t, err) + assert.Equal(t, OutcomeSkipped, skipped.Outcome) + + bundle.PatronRequest.Patron = pgtype.Text{String: "updated-patron", Valid: true} + bundle.Items = []pr_db.SaveItemParams{{ID: prefix + "-item-new", Barcode: "new", CreatedAt: testTimestamp(4)}} + bundle.Notifications = nil + bundle.LocatedSuppliers = nil + updated, err := importTestRepo.ImportPatronRequest(importTestCtx, bundle, ConflictPolicyUpdate) + require.NoError(t, err) + assert.Equal(t, OutcomeImported, updated.Outcome) + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM item WHERE pr_id=$1", bundle.PatronRequest.ID)) + assert.Equal(t, 0, queryCount(t, "SELECT count(*) FROM notification WHERE pr_id=$1", bundle.PatronRequest.ID)) + assert.Equal(t, 0, queryCount(t, "SELECT count(*) FROM located_supplier WHERE ill_transaction_id=$1", bundle.IllTransaction.ID)) + var patron string + require.NoError(t, importTestPool.QueryRow(context.Background(), "SELECT patron FROM patron_request WHERE id=$1", bundle.PatronRequest.ID).Scan(&patron)) + assert.Equal(t, "updated-patron", patron) +} + +func TestImportPatronRequestCollisionRollsBackRoot(t *testing.T) { + prefix := uuid.NewString() + first := testPatronBundle(prefix+"-first", prefix+"-request-first") + first.IllTransaction = nil + first.LocatedSuppliers = nil + require.NoError(t, importOnly(first)) + + second := testPatronBundle(prefix+"-second", prefix+"-request-second") + second.IllTransaction = nil + second.LocatedSuppliers = nil + second.Items[0].ID = first.Items[0].ID + _, err := importTestRepo.ImportPatronRequest(importTestCtx, second, ConflictPolicyFail) + require.Error(t, err) + assert.Equal(t, 0, queryCount(t, "SELECT count(*) FROM patron_request WHERE id=$1", second.PatronRequest.ID)) + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM item WHERE id=$1 AND pr_id=$2", first.Items[0].ID, first.PatronRequest.ID)) +} + +func TestImportPatronRequestCannotOmitExistingIllAssociation(t *testing.T) { + prefix := uuid.NewString() + bundle := testPatronBundle(prefix, prefix+"-request") + require.NoError(t, importOnly(bundle)) + bundle.IllTransaction = nil + bundle.LocatedSuppliers = nil + _, err := importTestRepo.ImportPatronRequest(importTestCtx, bundle, ConflictPolicyUpdate) + require.ErrorContains(t, err, "cannot be omitted") + assert.Equal(t, 1, queryCount(t, "SELECT count(*) FROM ill_transaction WHERE requester_request_id=$1", bundle.PatronRequest.RequesterReqID.String)) +} + +func TestImportPatronRequestNewRootCannotReuseExistingIllTransaction(t *testing.T) { + prefix := uuid.NewString() + original := testPatronBundle(prefix+"-original", prefix+"-request") + require.NoError(t, importOnly(original)) + incoming := testPatronBundle(prefix+"-incoming", prefix+"-request") + incoming.IllTransaction.ID = original.IllTransaction.ID + _, err := importTestRepo.ImportPatronRequest(importTestCtx, incoming, ConflictPolicyUpdate) + require.ErrorContains(t, err, "already belongs to a persisted aggregate") + assert.Equal(t, 0, queryCount(t, "SELECT count(*) FROM patron_request WHERE id=$1", incoming.PatronRequest.ID)) +} + +func testPatronBundle(prefix, requesterRequestID string) PatronRequestBundle { + requesterPeerID := prefix + "-requester-peer" + supplierPeerID := prefix + "-supplier-peer" + insertPeer(prefix+"-REQ", requesterPeerID) + insertPeer(prefix+"-SUP", supplierPeerID) + return PatronRequestBundle{ + PatronRequest: pr_db.CreatePatronRequestParams{ID: prefix + "-pr", CreatedAt: testTimestamp(0), UpdatedAt: testTimestamp(1), IllRequest: iso18626.Request{}, State: "SENT", Side: "borrowing", Patron: pgtype.Text{String: "original", Valid: true}, RequesterSymbol: pgtype.Text{String: prefix + "-REQ", Valid: true}, Tenant: pgtype.Text{String: "tenant", Valid: true}, RequesterReqID: pgtype.Text{String: requesterRequestID, Valid: true}, Items: []pr_db.PrItem{}, Language: pr_db.LANGUAGE, StateModel: "default"}, + Items: []pr_db.SaveItemParams{{ID: prefix + "-item", Barcode: "barcode", CreatedAt: testTimestamp(2)}}, + Notifications: []pr_db.SaveNotificationParams{{ID: prefix + "-notification", FromSymbol: prefix + "-REQ", ToSymbol: prefix + "-SUP", Direction: pr_db.NotificationDirectionSent, Kind: pr_db.NotificationKindNote, CreatedAt: testTimestamp(3)}}, + IllTransaction: &ill_db.SaveIllTransactionParams{ID: prefix + "-ill", Timestamp: testTimestamp(0), RequesterSymbol: pgtype.Text{String: prefix + "-REQ", Valid: true}, RequesterID: pgtype.Text{String: requesterPeerID, Valid: true}, RequesterRequestID: pgtype.Text{String: requesterRequestID, Valid: true}, IllTransactionData: ill_db.IllTransactionData{}}, + LocatedSuppliers: []ill_db.SaveLocatedSupplierParams{{ID: prefix + "-located", SupplierID: supplierPeerID, SupplierSymbol: prefix + "-SUP", Ordinal: 1}}, + } +} + +func insertPeer(symbol, id string) { + _, err := importTestPool.Exec(context.Background(), `INSERT INTO peer (id,name,refresh_policy,url,vendor,broker_mode) VALUES ($1,$1,'never','http://example.test','test','transparent') ON CONFLICT DO NOTHING`, id) + if err != nil { + panic(err) + } + _, err = importTestPool.Exec(context.Background(), `INSERT INTO symbol (symbol_value,peer_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, symbol, id) + if err != nil { + panic(err) + } +} + +func testTimestamp(offset int) pgtype.Timestamp { + return pgtype.Timestamp{Time: time.Date(2026, 8, 1, 10, offset, 0, 0, time.UTC), Valid: true} +} +func importOnly(bundle PatronRequestBundle) error { + _, err := importTestRepo.ImportPatronRequest(importTestCtx, bundle, ConflictPolicyFail) + return err +} +func queryCount(t *testing.T, query string, args ...any) int { + t.Helper() + var count int + require.NoError(t, importTestPool.QueryRow(context.Background(), query, args...).Scan(&count)) + return count +} diff --git a/broker/import/db/policy_test.go b/broker/import/db/policy_test.go new file mode 100644 index 00000000..1dd7789f --- /dev/null +++ b/broker/import/db/policy_test.go @@ -0,0 +1,94 @@ +package importdb + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/indexdata/crosslink/broker/events" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImportTemplatePolicies(t *testing.T) { + owner := uuid.NewString() + original := pr_db.SaveTemplateParams{ID: uuid.NewString(), Owner: owner, Title: "Original", Purpose: "email", Body: "body", ContentType: "text/plain", Labels: []string{"notice"}, Audience: pgtype.Text{String: "patron", Valid: true}, CreatedAt: testTimestamp(0), UpdatedAt: testTimestamp(1)} + result, err := importTestRepo.ImportTemplate(importTestCtx, original, ConflictPolicyFail) + require.NoError(t, err) + assert.Equal(t, OutcomeImported, result.Outcome) + + incoming := original + incoming.ID = uuid.NewString() + incoming.Title = "Updated" + _, err = importTestRepo.ImportTemplate(importTestCtx, incoming, ConflictPolicyFail) + var conflict *ConflictError + assert.ErrorAs(t, err, &conflict) + skipped, err := importTestRepo.ImportTemplate(importTestCtx, incoming, ConflictPolicySkip) + require.NoError(t, err) + assert.Equal(t, OutcomeSkipped, skipped.Outcome) + updated, err := importTestRepo.ImportTemplate(importTestCtx, incoming, ConflictPolicyUpdate) + require.NoError(t, err) + assert.Equal(t, OutcomeImported, updated.Outcome) + + var id, title string + require.NoError(t, importTestPool.QueryRow(context.Background(), "SELECT id,title FROM template WHERE owner=$1", owner).Scan(&id, &title)) + assert.Equal(t, original.ID, id) + assert.Equal(t, "Updated", title) +} + +func TestImportTemplateUpdateRejectsAmbiguousLabelOverlap(t *testing.T) { + owner := uuid.NewString() + for _, label := range []string{"first", "second"} { + params := pr_db.SaveTemplateParams{ID: uuid.NewString(), Owner: owner, Title: label, Purpose: "email", Body: "body", ContentType: "text/plain", Labels: []string{label}, Audience: pgtype.Text{String: "patron", Valid: true}, CreatedAt: testTimestamp(0), UpdatedAt: testTimestamp(1)} + _, err := importTestRepo.ImportTemplate(importTestCtx, params, ConflictPolicyFail) + require.NoError(t, err) + } + incoming := pr_db.SaveTemplateParams{ID: uuid.NewString(), Owner: owner, Title: "ambiguous", Purpose: "email", Body: "body", ContentType: "text/plain", Labels: []string{"first", "second"}, Audience: pgtype.Text{String: "patron", Valid: true}, CreatedAt: testTimestamp(0), UpdatedAt: testTimestamp(1)} + _, err := importTestRepo.ImportTemplate(importTestCtx, incoming, ConflictPolicyUpdate) + require.ErrorContains(t, err, "labels overlap multiple templates") + assert.Equal(t, 2, queryCount(t, "SELECT count(*) FROM template WHERE owner=$1", owner)) +} + +func TestImportBatchActionPolicies(t *testing.T) { + _, err := importTestPool.Exec(context.Background(), "INSERT INTO event_config(event_name,event_type) VALUES ($1,'scheduled') ON CONFLICT DO NOTHING", events.EventNameInvokeBatchAction) + require.NoError(t, err) + owner := uuid.NewString() + listener, err := importTestPool.Acquire(context.Background()) + require.NoError(t, err) + defer listener.Release() + _, err = listener.Exec(context.Background(), "LISTEN "+sched_db.SchedulerChannel) + require.NoError(t, err) + original := sched_db.SaveScheduledTaskParams{ID: uuid.NewString(), EventName: events.EventNameInvokeBatchAction, Schedule: "FREQ=DAILY", ActionData: events.EventData{}, Title: pgtype.Text{String: "Daily", Valid: true}, Status: sched_db.ScheduledTaskStatusPending, Owner: owner, CreatedAt: pgtype.Timestamptz{Time: testTimestamp(0).Time, Valid: true}, UpdatedAt: pgtype.Timestamptz{Time: testTimestamp(1).Time, Valid: true}} + result, err := importTestRepo.ImportBatchAction(importTestCtx, original, ConflictPolicyFail) + require.NoError(t, err) + assert.Equal(t, OutcomeImported, result.Outcome) + notifyCtx, cancelNotify := context.WithTimeout(context.Background(), time.Second) + _, err = listener.Conn().WaitForNotification(notifyCtx) + cancelNotify() + require.NoError(t, err) + + incoming := original + incoming.ID = uuid.NewString() + incoming.Schedule = "FREQ=WEEKLY" + _, err = importTestRepo.ImportBatchAction(importTestCtx, incoming, ConflictPolicyFail) + assert.Error(t, err) + skipped, err := importTestRepo.ImportBatchAction(importTestCtx, incoming, ConflictPolicySkip) + require.NoError(t, err) + assert.Equal(t, OutcomeSkipped, skipped.Outcome) + quietCtx, cancelQuiet := context.WithTimeout(context.Background(), 100*time.Millisecond) + _, err = listener.Conn().WaitForNotification(quietCtx) + cancelQuiet() + assert.ErrorIs(t, err, context.DeadlineExceeded) + updated, err := importTestRepo.ImportBatchAction(importTestCtx, incoming, ConflictPolicyUpdate) + require.NoError(t, err) + assert.Equal(t, OutcomeImported, updated.Outcome) + + var id, schedule string + require.NoError(t, importTestPool.QueryRow(context.Background(), "SELECT id,schedule FROM scheduled_task WHERE owner=$1", owner).Scan(&id, &schedule)) + assert.Equal(t, original.ID, id) + assert.Equal(t, "FREQ=WEEKLY", schedule) +} diff --git a/broker/import/db/repo.go b/broker/import/db/repo.go new file mode 100644 index 00000000..42f274a9 --- /dev/null +++ b/broker/import/db/repo.go @@ -0,0 +1,315 @@ +package importdb + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/indexdata/crosslink/broker/common" + "github.com/indexdata/crosslink/broker/ill_db" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + brokerepo "github.com/indexdata/crosslink/broker/repo" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PgImportRepo struct { + brokerepo.PgBaseRepo[ImportRepo] + queries *Queries + prQueries *pr_db.Queries + illQueries *ill_db.Queries + schedQueries *sched_db.Queries +} + +// WithTxFunc delegates transaction handling to PgBaseRepo. +func (r *PgImportRepo) WithTxFunc(ctx common.ExtendedContext, fn func(ImportRepo) error) error { + return r.PgBaseRepo.WithTxFunc(ctx, r, fn) +} + +// CreateWithPgBaseRepo creates a derived repo bound to the provided tx-aware base. +func (r *PgImportRepo) CreateWithPgBaseRepo(base *brokerepo.PgBaseRepo[ImportRepo]) ImportRepo { + derived := new(PgImportRepo) + derived.PgBaseRepo = *base + derived.queries = r.queries + derived.prQueries = r.prQueries + derived.illQueries = r.illQueries + derived.schedQueries = r.schedQueries + return derived +} + +func (r *PgImportRepo) withTxConn(ctx common.ExtendedContext, fn func(DBTX) error) error { + return r.WithTxFunc(ctx, func(txRepo ImportRepo) error { + txImportRepo, ok := txRepo.(*PgImportRepo) + if !ok { + return errors.New("unexpected import repo implementation") + } + return fn(txImportRepo.GetConnOrTx()) + }) +} + +func CreateImportRepo(pool *pgxpool.Pool) ImportRepo { + r := new(PgImportRepo) + r.Pool = pool + r.queries = New() + r.prQueries = pr_db.New() + r.illQueries = ill_db.New() + r.schedQueries = sched_db.New() + return r +} + +func (r *PgImportRepo) ImportPatronRequest(ctx common.ExtendedContext, bundle PatronRequestBundle, policy ConflictPolicy) (Result, error) { + if err := policy.validate(); err != nil { + return Result{}, err + } + var result Result + err := r.withTxConn(ctx, func(tx DBTX) error { + existing, err := r.queries.LockImportPatronRequest(ctx, tx, bundle.PatronRequest.ID) + exists := err == nil + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lock patron request %q: %w", bundle.PatronRequest.ID, err) + } + if exists { + switch policy { + case ConflictPolicyFail: + return &ConflictError{Resource: "patron request", Identifier: bundle.PatronRequest.ID} + case ConflictPolicySkip: + result = Result{Outcome: OutcomeSkipped, Diagnostic: fmt.Sprintf("patron request %q already exists", bundle.PatronRequest.ID)} + return nil + case ConflictPolicyUpdate: + if existing.RequesterReqID != bundle.PatronRequest.RequesterReqID { + return &ConflictError{Resource: "patron request", Identifier: bundle.PatronRequest.ID, Reason: "requester request ID does not match existing aggregate"} + } + default: + return fmt.Errorf("unsupported conflict policy %q", policy) + } + } + + if exists { + if err := r.queries.UpdateImportedPatronRequest(ctx, tx, importedPatronRequestParams(bundle.PatronRequest)); err != nil { + return fmt.Errorf("update patron request %q: %w", bundle.PatronRequest.ID, err) + } + } else { + if _, err := r.prQueries.CreatePatronRequest(ctx, tx, bundle.PatronRequest); err != nil { + return fmt.Errorf("insert patron request %q: %w", bundle.PatronRequest.ID, err) + } + } + + itemIDs := make([]string, 0, len(bundle.Items)) + for _, item := range bundle.Items { + if err := ensureImportParent(ctx, item.ID, bundle.PatronRequest.ID, "item", func(id string) (string, error) { + return r.queries.GetImportItemParent(ctx, tx, id) + }); err != nil { + return err + } + item.PrID = bundle.PatronRequest.ID + if _, err := r.prQueries.SaveItem(ctx, tx, item); err != nil { + return fmt.Errorf("save item %q: %w", item.ID, err) + } + itemIDs = append(itemIDs, item.ID) + } + if exists { + if err := r.queries.DeleteImportedItemsNotPresent(ctx, tx, DeleteImportedItemsNotPresentParams{PrID: bundle.PatronRequest.ID, Ids: itemIDs}); err != nil { + return fmt.Errorf("synchronize items for patron request %q: %w", bundle.PatronRequest.ID, err) + } + } + + notificationIDs := make([]string, 0, len(bundle.Notifications)) + for _, notification := range bundle.Notifications { + if err := ensureImportParent(ctx, notification.ID, bundle.PatronRequest.ID, "notification", func(id string) (string, error) { + return r.queries.GetImportNotificationParent(ctx, tx, id) + }); err != nil { + return err + } + notification.PrID = bundle.PatronRequest.ID + if _, err := r.prQueries.SaveNotification(ctx, tx, notification); err != nil { + return fmt.Errorf("save notification %q: %w", notification.ID, err) + } + notificationIDs = append(notificationIDs, notification.ID) + } + if exists { + if err := r.queries.DeleteImportedNotificationsNotPresent(ctx, tx, DeleteImportedNotificationsNotPresentParams{PrID: bundle.PatronRequest.ID, Ids: notificationIDs}); err != nil { + return fmt.Errorf("synchronize notifications for patron request %q: %w", bundle.PatronRequest.ID, err) + } + } + + if err := r.saveIllAggregate(ctx, tx, bundle, exists); err != nil { + return err + } + result = Result{Outcome: OutcomeImported} + return nil + }) + return result, err +} + +func importedPatronRequestParams(params pr_db.CreatePatronRequestParams) UpdateImportedPatronRequestParams { + return UpdateImportedPatronRequestParams{ + ID: params.ID, CreatedAt: params.CreatedAt, IllRequest: params.IllRequest, + State: params.State, Side: params.Side, Patron: params.Patron, + RequesterSymbol: params.RequesterSymbol, SupplierSymbol: params.SupplierSymbol, + Tenant: params.Tenant, RequesterReqID: params.RequesterReqID, + NeedsAttention: params.NeedsAttention, LastAction: params.LastAction, + LastActionOutcome: params.LastActionOutcome, LastActionResult: params.LastActionResult, + Items: params.Items, Language: params.Language, TerminalState: params.TerminalState, + UpdatedAt: params.UpdatedAt, IllResponse: params.IllResponse, InternalNote: params.InternalNote, + NextReqID: params.NextReqID, PrevReqID: params.PrevReqID, + RetryBibInfo: params.RetryBibInfo, StateModel: params.StateModel, + } +} + +func ensureImportParent(ctx context.Context, id, expectedParent, resource string, getParent func(string) (string, error)) error { + parent, err := getParent(id) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("check %s %q ownership: %w", resource, id, err) + } + if parent != expectedParent { + return &ConflictError{Resource: resource, Identifier: id, Reason: fmt.Sprintf("belongs to %q, not %q", parent, expectedParent)} + } + return nil +} + +func (r *PgImportRepo) saveIllAggregate(ctx common.ExtendedContext, tx DBTX, bundle PatronRequestBundle, updating bool) error { + requesterRequestID := bundle.PatronRequest.RequesterReqID + var associated GetImportIllTransactionByRequesterRequestIDRow + associationExists := false + if requesterRequestID.Valid { + var err error + associated, err = r.queries.GetImportIllTransactionByRequesterRequestID(ctx, tx, requesterRequestID) + associationExists = err == nil + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("check ILL association for requester request %q: %w", requesterRequestID.String, err) + } + } + if bundle.IllTransaction == nil { + if associationExists { + return &ConflictError{Resource: "ILL transaction", Identifier: associated.ID, Reason: "cannot be omitted from an existing patron request aggregate"} + } + if len(bundle.LocatedSuppliers) != 0 { + return fmt.Errorf("located suppliers require an ILL transaction") + } + return nil + } + + ill := *bundle.IllTransaction + if ill.RequesterRequestID != requesterRequestID { + return &ConflictError{Resource: "ILL transaction", Identifier: ill.ID, Reason: "requester request ID does not match patron request"} + } + locked, err := r.queries.LockImportIllTransaction(ctx, tx, ill.ID) + illExists := err == nil + if err == nil && locked.RequesterRequestID != ill.RequesterRequestID { + return &ConflictError{Resource: "ILL transaction", Identifier: ill.ID, Reason: "requester request ID does not match existing transaction"} + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lock ILL transaction %q: %w", ill.ID, err) + } + if !updating && (associationExists || illExists) { + return &ConflictError{Resource: "ILL transaction", Identifier: ill.ID, Reason: "already belongs to a persisted aggregate"} + } + if associationExists && associated.ID != ill.ID { + return &ConflictError{Resource: "ILL transaction", Identifier: ill.ID, Reason: fmt.Sprintf("requester request ID is already associated with %q", associated.ID)} + } + if _, err := r.illQueries.SaveIllTransaction(ctx, tx, ill); err != nil { + return fmt.Errorf("save ILL transaction %q: %w", ill.ID, err) + } + + locatedSupplierIDs := make([]string, 0, len(bundle.LocatedSuppliers)) + for _, supplier := range bundle.LocatedSuppliers { + if err := ensureImportParent(ctx, supplier.ID, ill.ID, "located supplier", func(id string) (string, error) { + return r.queries.GetImportLocatedSupplierParent(ctx, tx, id) + }); err != nil { + return err + } + supplier.IllTransactionID = ill.ID + if _, err := r.illQueries.SaveLocatedSupplier(ctx, tx, supplier); err != nil { + return fmt.Errorf("save located supplier %q: %w", supplier.ID, err) + } + locatedSupplierIDs = append(locatedSupplierIDs, supplier.ID) + } + if updating || illExists { + if err := r.queries.DeleteImportedLocatedSuppliersNotPresent(ctx, tx, DeleteImportedLocatedSuppliersNotPresentParams{IllTransactionID: ill.ID, Ids: locatedSupplierIDs}); err != nil { + return fmt.Errorf("synchronize located suppliers for ILL transaction %q: %w", ill.ID, err) + } + } + return nil +} + +func (r *PgImportRepo) ImportTemplate(ctx common.ExtendedContext, params pr_db.SaveTemplateParams, policy ConflictPolicy) (Result, error) { + if err := policy.validate(); err != nil { + return Result{}, err + } + var result Result + err := r.withTxConn(ctx, func(tx DBTX) error { + matches, err := r.queries.LockImportTemplatesByLabels(ctx, tx, LockImportTemplatesByLabelsParams{Owner: params.Owner, Labels: params.Labels}) + if err != nil { + return fmt.Errorf("lock templates for owner %q: %w", params.Owner, err) + } + if len(matches) != 0 { + switch policy { + case ConflictPolicyFail: + return &ConflictError{Resource: "template", Identifier: params.ID, Reason: "labels overlap an existing template"} + case ConflictPolicySkip: + result = Result{Outcome: OutcomeSkipped, Diagnostic: fmt.Sprintf("template labels overlap existing template %q", matches[0].ID)} + return nil + case ConflictPolicyUpdate: + if len(matches) != 1 { + ids := make([]string, len(matches)) + for i, match := range matches { + ids[i] = match.ID + } + return &ConflictError{Resource: "template", Identifier: params.ID, Reason: "labels overlap multiple templates: " + strings.Join(ids, ", ")} + } + params.ID = matches[0].ID + params.CreatedAt = matches[0].CreatedAt + default: + return fmt.Errorf("unsupported conflict policy %q", policy) + } + } + if _, err := r.prQueries.SaveTemplate(ctx, tx, params); err != nil { + return fmt.Errorf("save template %q: %w", params.ID, err) + } + result = Result{Outcome: OutcomeImported} + return nil + }) + return result, err +} + +func (r *PgImportRepo) ImportBatchAction(ctx common.ExtendedContext, params sched_db.SaveScheduledTaskParams, policy ConflictPolicy) (Result, error) { + if err := policy.validate(); err != nil { + return Result{}, err + } + var result Result + err := r.withTxConn(ctx, func(tx DBTX) error { + existing, err := r.queries.LockImportBatchAction(ctx, tx, LockImportBatchActionParams{Owner: params.Owner, Title: params.Title}) + exists := err == nil + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("lock batch action %q: %w", params.Title.String, err) + } + if exists { + switch policy { + case ConflictPolicyFail: + return &ConflictError{Resource: "batch action", Identifier: params.ID, Reason: "owner and title already exist"} + case ConflictPolicySkip: + result = Result{Outcome: OutcomeSkipped, Diagnostic: fmt.Sprintf("batch action %q already exists", params.Title.String)} + return nil + case ConflictPolicyUpdate: + params.ID = existing.ID + params.CreatedAt = existing.CreatedAt + default: + return fmt.Errorf("unsupported conflict policy %q", policy) + } + } + if _, err := r.schedQueries.SaveScheduledTask(ctx, tx, params); err != nil { + return fmt.Errorf("save batch action %q: %w", params.ID, err) + } + if _, err := tx.Exec(ctx, "NOTIFY "+sched_db.SchedulerChannel); err != nil { + return fmt.Errorf("notify scheduler: %w", err) + } + result = Result{Outcome: OutcomeImported} + return nil + }) + return result, err +} diff --git a/broker/import/db/repo_test.go b/broker/import/db/repo_test.go new file mode 100644 index 00000000..86d4317a --- /dev/null +++ b/broker/import/db/repo_test.go @@ -0,0 +1,84 @@ +package importdb + +import ( + "context" + "errors" + "testing" + + "github.com/indexdata/crosslink/broker/ill_db" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + brokerepo "github.com/indexdata/crosslink/broker/repo" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateImportRepoInitializesDependencies(t *testing.T) { + repo := CreateImportRepo(nil) + pgRepo, ok := repo.(*PgImportRepo) + require.True(t, ok) + + assert.Nil(t, pgRepo.Pool) + assert.NotNil(t, pgRepo.queries) + assert.NotNil(t, pgRepo.prQueries) + assert.NotNil(t, pgRepo.illQueries) + assert.NotNil(t, pgRepo.schedQueries) +} + +func TestCreateWithPgBaseRepoCopiesBaseAndQueries(t *testing.T) { + source := &PgImportRepo{ + queries: New(), + prQueries: pr_db.New(), + illQueries: ill_db.New(), + schedQueries: sched_db.New(), + } + base := &brokerepo.PgBaseRepo[ImportRepo]{} + + derivedRepo := source.CreateWithPgBaseRepo(base) + derived, ok := derivedRepo.(*PgImportRepo) + require.True(t, ok) + + assert.Equal(t, *base, derived.PgBaseRepo) + assert.Same(t, source.queries, derived.queries) + assert.Same(t, source.prQueries, derived.prQueries) + assert.Same(t, source.illQueries, derived.illQueries) + assert.Same(t, source.schedQueries, derived.schedQueries) +} + +func TestEnsureImportParent(t *testing.T) { + t.Run("missing row is allowed", func(t *testing.T) { + err := ensureImportParent(context.Background(), "id-1", "pr-1", "item", func(string) (string, error) { + return "", pgx.ErrNoRows + }) + require.NoError(t, err) + }) + + t.Run("query failure is wrapped", func(t *testing.T) { + dbErr := errors.New("db down") + err := ensureImportParent(context.Background(), "id-1", "pr-1", "item", func(string) (string, error) { + return "", dbErr + }) + require.Error(t, err) + assert.ErrorIs(t, err, dbErr) + assert.Contains(t, err.Error(), "check item \"id-1\" ownership") + }) + + t.Run("mismatched parent returns conflict", func(t *testing.T) { + err := ensureImportParent(context.Background(), "id-1", "pr-1", "item", func(string) (string, error) { + return "pr-2", nil + }) + var conflictErr *ConflictError + require.ErrorAs(t, err, &conflictErr) + assert.Equal(t, "item", conflictErr.Resource) + assert.Equal(t, "id-1", conflictErr.Identifier) + assert.Contains(t, conflictErr.Reason, "belongs to \"pr-2\"") + }) + + t.Run("matching parent succeeds", func(t *testing.T) { + err := ensureImportParent(context.Background(), "id-1", "pr-1", "item", func(string) (string, error) { + return "pr-1", nil + }) + require.NoError(t, err) + }) +} diff --git a/broker/import/oapi/import_openapi_gen.go b/broker/import/oapi/import_openapi_gen.go new file mode 100644 index 00000000..1c1c2cf2 --- /dev/null +++ b/broker/import/oapi/import_openapi_gen.go @@ -0,0 +1,2320 @@ +//go:build go1.22 + +// Package importoapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package importoapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/indexdata/crosslink/broker/events" + "github.com/indexdata/crosslink/broker/ill_db" + dirapi "github.com/indexdata/crosslink/directory/api" + "github.com/indexdata/crosslink/iso18626" + "github.com/oapi-codegen/runtime" +) + +// Defines values for ActionCapabilityKind. +const ( + Operation ActionCapabilityKind = "operation" + Transition ActionCapabilityKind = "transition" +) + +// Valid indicates whether the value is a known member of the ActionCapabilityKind enum. +func (e ActionCapabilityKind) Valid() bool { + switch e { + case Operation: + return true + case Transition: + return true + default: + return false + } +} + +// Defines values for BatchActionName. +const ( + EmailPullslips BatchActionName = "email-pullslips" + RequestAging BatchActionName = "request-aging" +) + +// Valid indicates whether the value is a known member of the BatchActionName enum. +func (e BatchActionName) Valid() bool { + switch e { + case EmailPullslips: + return true + case RequestAging: + return true + default: + return false + } +} + +// Defines values for ConflictPolicy. +const ( + Fail ConflictPolicy = "fail" + Skip ConflictPolicy = "skip" + Update ConflictPolicy = "update" +) + +// Valid indicates whether the value is a known member of the ConflictPolicy enum. +func (e ConflictPolicy) Valid() bool { + switch e { + case Fail: + return true + case Skip: + return true + case Update: + return true + default: + return false + } +} + +// Defines values for ImportItemType. +const ( + ImportItemTypeBatchAction ImportItemType = "batchAction" + ImportItemTypePatronRequest ImportItemType = "patronRequest" + ImportItemTypeTemplate ImportItemType = "template" +) + +// Valid indicates whether the value is a known member of the ImportItemType enum. +func (e ImportItemType) Valid() bool { + switch e { + case ImportItemTypeBatchAction: + return true + case ImportItemTypePatronRequest: + return true + case ImportItemTypeTemplate: + return true + default: + return false + } +} + +// Defines values for ImportLocatedSupplierSupplierStatus. +const ( + New ImportLocatedSupplierSupplierStatus = "new" + Selected ImportLocatedSupplierSupplierStatus = "selected" + Skipped ImportLocatedSupplierSupplierStatus = "skipped" +) + +// Valid indicates whether the value is a known member of the ImportLocatedSupplierSupplierStatus enum. +func (e ImportLocatedSupplierSupplierStatus) Valid() bool { + switch e { + case New: + return true + case Selected: + return true + case Skipped: + return true + default: + return false + } +} + +// Defines values for ImportPatronRequestNotificationDirection. +const ( + Received ImportPatronRequestNotificationDirection = "received" + Sent ImportPatronRequestNotificationDirection = "sent" +) + +// Valid indicates whether the value is a known member of the ImportPatronRequestNotificationDirection enum. +func (e ImportPatronRequestNotificationDirection) Valid() bool { + switch e { + case Received: + return true + case Sent: + return true + default: + return false + } +} + +// Defines values for ImportPatronRequestNotificationKind. +const ( + ImportPatronRequestNotificationKindCondition ImportPatronRequestNotificationKind = "condition" + ImportPatronRequestNotificationKindNote ImportPatronRequestNotificationKind = "note" +) + +// Valid indicates whether the value is a known member of the ImportPatronRequestNotificationKind enum. +func (e ImportPatronRequestNotificationKind) Valid() bool { + switch e { + case ImportPatronRequestNotificationKindCondition: + return true + case ImportPatronRequestNotificationKindNote: + return true + default: + return false + } +} + +// Defines values for ModelActionParamsSendTo. +const ( + ModelActionParamsSendToPatron ModelActionParamsSendTo = "patron" + ModelActionParamsSendToStaff ModelActionParamsSendTo = "staff" +) + +// Valid indicates whether the value is a known member of the ModelActionParamsSendTo enum. +func (e ModelActionParamsSendTo) Valid() bool { + switch e { + case ModelActionParamsSendToPatron: + return true + case ModelActionParamsSendToStaff: + return true + default: + return false + } +} + +// Defines values for ModelActionTrigger. +const ( + Auto ModelActionTrigger = "auto" + Manual ModelActionTrigger = "manual" +) + +// Valid indicates whether the value is a known member of the ModelActionTrigger enum. +func (e ModelActionTrigger) Valid() bool { + switch e { + case Auto: + return true + case Manual: + return true + default: + return false + } +} + +// Defines values for ModelStateSide. +const ( + REQUESTER ModelStateSide = "REQUESTER" + SUPPLIER ModelStateSide = "SUPPLIER" +) + +// Valid indicates whether the value is a known member of the ModelStateSide enum. +func (e ModelStateSide) Valid() bool { + switch e { + case REQUESTER: + return true + case SUPPLIER: + return true + default: + return false + } +} + +// Defines values for PeerBrokerMode. +const ( + Opaque PeerBrokerMode = "opaque" + Translucent PeerBrokerMode = "translucent" + Transparent PeerBrokerMode = "transparent" +) + +// Valid indicates whether the value is a known member of the PeerBrokerMode enum. +func (e PeerBrokerMode) Valid() bool { + switch e { + case Opaque: + return true + case Translucent: + return true + case Transparent: + return true + default: + return false + } +} + +// Defines values for PeerRefreshPolicy. +const ( + Never PeerRefreshPolicy = "never" + Transaction PeerRefreshPolicy = "transaction" +) + +// Valid indicates whether the value is a known member of the PeerRefreshPolicy enum. +func (e PeerRefreshPolicy) Valid() bool { + switch e { + case Never: + return true + case Transaction: + return true + default: + return false + } +} + +// Defines values for PrNotificationKind. +const ( + PrNotificationKindCondition PrNotificationKind = "condition" + PrNotificationKindNote PrNotificationKind = "note" +) + +// Valid indicates whether the value is a known member of the PrNotificationKind enum. +func (e PrNotificationKind) Valid() bool { + switch e { + case PrNotificationKindCondition: + return true + case PrNotificationKindNote: + return true + default: + return false + } +} + +// Defines values for PullSlipType. +const ( + Batch PullSlipType = "batch" + Single PullSlipType = "single" +) + +// Valid indicates whether the value is a known member of the PullSlipType enum. +func (e PullSlipType) Valid() bool { + switch e { + case Batch: + return true + case Single: + return true + default: + return false + } +} + +// Defines values for StateModelType. +const ( + StateModelTypeStateModel StateModelType = "StateModel" +) + +// Valid indicates whether the value is a known member of the StateModelType enum. +func (e StateModelType) Valid() bool { + switch e { + case StateModelTypeStateModel: + return true + default: + return false + } +} + +// Defines values for StateModelServiceType. +const ( + Copy StateModelServiceType = "Copy" + CopyOrLoan StateModelServiceType = "CopyOrLoan" + Loan StateModelServiceType = "Loan" +) + +// Valid indicates whether the value is a known member of the StateModelServiceType enum. +func (e StateModelServiceType) Valid() bool { + switch e { + case Copy: + return true + case CopyOrLoan: + return true + case Loan: + return true + default: + return false + } +} + +// Defines values for TemplateAudience. +const ( + TemplateAudiencePatron TemplateAudience = "patron" + TemplateAudienceStaff TemplateAudience = "staff" +) + +// Valid indicates whether the value is a known member of the TemplateAudience enum. +func (e TemplateAudience) Valid() bool { + switch e { + case TemplateAudiencePatron: + return true + case TemplateAudienceStaff: + return true + default: + return false + } +} + +// Defines values for TemplateContentType. +const ( + Html TemplateContentType = "html" + Text TemplateContentType = "text" +) + +// Valid indicates whether the value is a known member of the TemplateContentType enum. +func (e TemplateContentType) Valid() bool { + switch e { + case Html: + return true + case Text: + return true + default: + return false + } +} + +// Defines values for TemplatePurpose. +const ( + Email TemplatePurpose = "email" + Pullslip TemplatePurpose = "pullslip" +) + +// Valid indicates whether the value is a known member of the TemplatePurpose enum. +func (e TemplatePurpose) Valid() bool { + switch e { + case Email: + return true + case Pullslip: + return true + default: + return false + } +} + +// Defines values for UpdateNotificationReceiptReceipt. +const ( + ACCEPTED UpdateNotificationReceiptReceipt = "ACCEPTED" + FAILEDTOSEND UpdateNotificationReceiptReceipt = "FAILED_TO_SEND" + REJECTED UpdateNotificationReceiptReceipt = "REJECTED" + SEEN UpdateNotificationReceiptReceipt = "SEEN" + SENT UpdateNotificationReceiptReceipt = "SENT" +) + +// Valid indicates whether the value is a known member of the UpdateNotificationReceiptReceipt enum. +func (e UpdateNotificationReceiptReceipt) Valid() bool { + switch e { + case ACCEPTED: + return true + case FAILEDTOSEND: + return true + case REJECTED: + return true + case SEEN: + return true + case SENT: + return true + default: + return false + } +} + +// Defines values for NotificationKind. +const ( + NotificationKindCondition NotificationKind = "condition" + NotificationKindNote NotificationKind = "note" +) + +// Valid indicates whether the value is a known member of the NotificationKind enum. +func (e NotificationKind) Valid() bool { + switch e { + case NotificationKindCondition: + return true + case NotificationKindNote: + return true + default: + return false + } +} + +// About defines model for About. +type About struct { + // Count Total number of items in the result + Count int64 `json:"count"` + + // FirstLink Link to the first page of results + FirstLink *string `json:"firstLink,omitempty"` + + // LastLink Link to the last page of results + LastLink *string `json:"lastLink,omitempty"` + + // NextLink Link to the next page of results + NextLink *string `json:"nextLink,omitempty"` + + // PrevLink Link to the previous page of results + PrevLink *string `json:"prevLink,omitempty"` +} + +// AboutWithFacets defines model for AboutWithFacets. +type AboutWithFacets struct { + // Count Total number of items in the result + Count int64 `json:"count"` + + // Facets List of facets for the result + Facets *FacetsResult `json:"facets,omitempty"` + + // FirstLink Link to the first page of results + FirstLink *string `json:"firstLink,omitempty"` + + // LastLink Link to the last page of results + LastLink *string `json:"lastLink,omitempty"` + + // NextLink Link to the next page of results + NextLink *string `json:"nextLink,omitempty"` + + // PrevLink Link to the previous page of results + PrevLink *string `json:"prevLink,omitempty"` +} + +// ActionCapability Definition of an action supported by the broker, including its execution kind and accepted parameters. +type ActionCapability struct { + // Kind How the action is executed. Operation actions invoke built-in domain logic; transition actions immediately succeed using the state model's configured success transition. + Kind *ActionCapabilityKind `json:"kind,omitempty"` + + // Name Name of the action + Name string `json:"name"` + + // Parameters List of parameter names for this action + Parameters []string `json:"parameters"` +} + +// ActionCapabilityKind How the action is executed. Operation actions invoke built-in domain logic; transition actions immediately succeed using the state model's configured success transition. +type ActionCapabilityKind string + +// ActionResult defines model for ActionResult. +type ActionResult struct { + // FromState State before action execution + FromState string `json:"fromState"` + + // Message Action message + Message *string `json:"message,omitempty"` + + // Outcome Action outcome ("success", "failure", "review") + Outcome string `json:"outcome"` + + // Result Action result + Result string `json:"result"` + + // ToState State after action execution + ToState *string `json:"toState,omitempty"` +} + +// AllowedAction defines model for AllowedAction. +type AllowedAction struct { + // Available Indicates if this action is available for execution in the current state + Available bool `json:"available"` + + // Name Name of the action + Name string `json:"name"` + + // Parameters List of parameters for this action + Parameters []string `json:"parameters"` + + // Primary Indicates if this action is primary action for the state + Primary *bool `json:"primary,omitempty"` +} + +// AllowedActions defines model for AllowedActions. +type AllowedActions struct { + Actions []AllowedAction `json:"actions"` +} + +// AppliesTo Criteria determining whether a state, action, or event applies to a request. When omitted, the element applies to all requests. +type AppliesTo struct { + // ServiceTypes ISO 18626 service types for which this element applies + ServiceTypes []StateModelServiceType `json:"serviceTypes"` +} + +// BatchAction A scheduled batch action +type BatchAction struct { + // ActionName Name of the batch action to run + ActionName BatchActionName `json:"actionName"` + + // ActionParams Parameters for the batch action. For request-aging, interval is required and must be a Go duration string such as "24h" or "168h". Additional parameters are passed to the generated patron-request background action. + ActionParams *map[string]interface{} `json:"actionParams,omitempty"` + + // Active Indicates if the batch action is active + Active bool `json:"active"` + + // BatchQuery Batch action selection query in CQL format + BatchQuery string `json:"batchQuery"` + + // CreatedAt Creation timestamp + CreatedAt time.Time `json:"createdAt"` + + // EventsLink Link to execution events for this batch action + EventsLink string `json:"eventsLink"` + + // Id Unique identifier of the batch action + Id string `json:"id"` + + // NextRun Next execution time according to the schedule + NextRun *time.Time `json:"nextRun,omitempty"` + + // Owner Symbol of the institution that owns the batch action. Empty for unrestricted actions created with master access. + Owner string `json:"owner"` + + // Schedule RRULE schedule expression, e.g. "FREQ=WEEKLY;BYDAY=MO;BYHOUR=6;BYMINUTE=0" (every Monday at 06:00) + Schedule string `json:"schedule"` + + // Title Title of the batch action, for display purposes + Title *string `json:"title,omitempty"` + + // UpdatedAt Last update timestamp + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// BatchActionDefault A built-in batch action that clients can offer as a starting point +type BatchActionDefault struct { + // ActionName Name of the batch action to run + ActionName BatchActionName `json:"actionName"` + + // ActionParams Parameters for the batch action. For request-aging, interval is required and must be a Go duration string such as "24h" or "168h". Additional parameters are passed to the generated patron-request background action. + ActionParams *map[string]interface{} `json:"actionParams,omitempty"` + + // BatchQuery Batch action selection query in CQL format + BatchQuery string `json:"batchQuery"` + + // Schedule RRULE schedule expression, e.g. "FREQ=WEEKLY;BYDAY=MO;BYHOUR=6;BYMINUTE=0" (every Monday at 06:00) + Schedule string `json:"schedule"` + + // Title Title of the batch action, for display purposes + Title *string `json:"title,omitempty"` + + // TitleKey Stable identifier for this default, for clients to look up a localized title + TitleKey string `json:"titleKey"` +} + +// BatchActionName Name of the batch action to run +type BatchActionName string + +// BatchActions defines model for BatchActions. +type BatchActions struct { + About About `json:"about"` + + // Items List of batch actions + Items []BatchAction `json:"items"` +} + +// BibliographicInfo Bibliographic information for an ILL request item, as defined in ISO18626 +type BibliographicInfo = map[string]interface{} + +// ConflictPolicy defines model for ConflictPolicy. +type ConflictPolicy string + +// CreateBatchAction Request body for creating a batch action +type CreateBatchAction struct { + // ActionName Name of the batch action to run + ActionName BatchActionName `json:"actionName"` + + // ActionParams Parameters for the batch action. For request-aging, interval is required and must be a Go duration string such as "24h" or "168h". Additional parameters are passed to the generated patron-request background action. + ActionParams *map[string]interface{} `json:"actionParams,omitempty"` + + // BatchQuery Batch action selection query in CQL format + BatchQuery string `json:"batchQuery"` + + // Schedule RRULE schedule expression, e.g. "FREQ=WEEKLY;BYDAY=MO;BYHOUR=6;BYMINUTE=0" (every Monday at 06:00) + Schedule string `json:"schedule"` + + // Title Title of the batch action, for display purposes + Title *string `json:"title,omitempty"` +} + +// CreatePatronRequest defines model for CreatePatronRequest. +type CreatePatronRequest struct { + // Id Unique identifier of the patron request + Id *string `json:"id,omitempty"` + + // IllRequest JSON of ISO18626 request + IllRequest map[string]interface{} `json:"illRequest"` + + // InternalNote Staff-only internal note, local to this request and never shared with peers + InternalNote *string `json:"internalNote,omitempty"` + + // Patron User who requested item + Patron *string `json:"patron,omitempty"` + + // RequesterSymbol Requester symbol + RequesterSymbol *string `json:"requesterSymbol,omitempty"` +} + +// CreatePrNotification Create patron request notification body +type CreatePrNotification struct { + // Note Note of notification + Note string `json:"note"` +} + +// CreatePullSlip Create pull slip body. If both illTransactionIds and cql are provided, illTransactionIds will be used for pull slip generation and cql will be ignored. +type CreatePullSlip struct { + // Cql CQL search for patron requests to create pull slips for + Cql *string `json:"cql,omitempty"` + + // IllTransactionIds Patron request IDs to create pull slips for + IllTransactionIds *[]string `json:"illTransactionIds,omitempty"` +} + +// CreateTemplate Request body for creating a template +type CreateTemplate struct { + // Audience Intended audience for the template. Omit to apply to both patron and staff. + Audience *TemplateAudience `json:"audience,omitempty"` + + // Body Body of the email or pull slip template. Supports {{.X}} placeholders. For full list of supported placeholders, see the Template object. + Body string `json:"body"` + + // ContentType Output content type for the template body + ContentType TemplateContentType `json:"contentType"` + + // Labels Labels identifying the template's usage context + Labels []string `json:"labels"` + + // Purpose Purpose of the template + Purpose TemplatePurpose `json:"purpose"` + + // Subject Subject line template, supports {{.X}} placeholders. Not used for pullslip templates. For full list of supported placeholders, see the Template object. + Subject *string `json:"subject,omitempty"` + + // Title Human-readable title for the template + Title string `json:"title"` +} + +// Error defines model for Error. +type Error struct { + // Error Error message + Error *string `json:"error,omitempty"` +} + +// Event defines model for Event. +type Event struct { + // EventData Data associated with the event + EventData *events.EventData `json:"eventData,omitempty"` + + // EventName Name of the event + EventName string `json:"eventName"` + + // EventStatus Status of the event + EventStatus string `json:"eventStatus"` + + // EventType Type of the event + EventType string `json:"eventType"` + + // Id Unique identifier of the event + Id string `json:"id"` + + // IllTransactionID ID of the ILL transaction (if applicable) + IllTransactionID string `json:"illTransactionID"` + + // ParentID Parent event ID + ParentID *string `json:"parentID,omitempty"` + + // PatronRequestID ID of the Patron request (if applicable) + PatronRequestID *string `json:"patronRequestID,omitempty"` + + // ResultData Result data of the event + ResultData *events.EventResult `json:"resultData,omitempty"` + + // Timestamp Timestamp of the event + Timestamp time.Time `json:"timestamp"` +} + +// Events defines model for Events. +type Events struct { + About About `json:"about"` + + // Items List of events + Items []Event `json:"items"` +} + +// ExecuteAction defines model for ExecuteAction. +type ExecuteAction struct { + // Action Action to execute + Action string `json:"action"` + + // ActionParams Action parameters + ActionParams *map[string]interface{} `json:"actionParams,omitempty"` +} + +// FacetResultValue defines model for FacetResultValue. +type FacetResultValue struct { + // Count Count of items for this facet value + Count int64 `json:"count"` + + // Label Human-readable name for the facet value, when one is available. Omitted when the value has no associated name. + Label *string `json:"label,omitempty"` + + // Value Facet value + Value string `json:"value"` +} + +// FacetsResult List of facets for the result +type FacetsResult = []struct { + // Name Facet name + Name string `json:"name"` + + // Values List of facet values. At most 100 entries are returned. + Values []FacetResultValue `json:"values"` +} + +// IllTransaction defines model for IllTransaction. +type IllTransaction struct { + // EventsLink Link to Ill Transaction events + EventsLink string `json:"eventsLink"` + + // Id Unique identifier for the ILL transaction + Id string `json:"id"` + + // IllTransactionData Result data of the event + IllTransactionData ill_db.IllTransactionData `json:"illTransactionData"` + + // LastRequesterAction Last action performed by the requester + LastRequesterAction string `json:"lastRequesterAction"` + + // LastSupplierStatus Last status update from the supplier + LastSupplierStatus string `json:"lastSupplierStatus"` + + // LocatedSuppliersLink Link to located Suppliers + LocatedSuppliersLink string `json:"locatedSuppliersLink"` + + // PrevRequesterAction Previous action performed by the requester + PrevRequesterAction string `json:"prevRequesterAction"` + + // PrevSupplierStatus Previous status update from the supplier + PrevSupplierStatus string `json:"prevSupplierStatus"` + + // RequesterID ID of the requesting institution + RequesterID string `json:"requesterID"` + + // RequesterPeerLink Link to requester Peer + RequesterPeerLink string `json:"requesterPeerLink"` + + // RequesterRequestID ID of the request from the requester's side + RequesterRequestID string `json:"requesterRequestID"` + + // RequesterSymbol Symbol of the requesting institution + RequesterSymbol string `json:"requesterSymbol"` + + // SupplierRequestID ID of the request from the supplier's side + SupplierRequestID string `json:"supplierRequestID"` + + // SupplierSymbol Symbol of the supplying institution + SupplierSymbol string `json:"supplierSymbol"` + + // Timestamp Timestamp of the transaction + Timestamp time.Time `json:"timestamp"` +} + +// IllTransactions defines model for IllTransactions. +type IllTransactions struct { + About About `json:"about"` + + // Items List of ILL transactions + Items []IllTransaction `json:"items"` +} + +// ImportIllTransaction defines model for ImportIllTransaction. +type ImportIllTransaction struct { + Id string `json:"id"` + IllTransactionData ill_db.IllTransactionData `json:"illTransactionData"` + LastRequesterAction *string `json:"lastRequesterAction,omitempty"` + LastSupplierStatus *string `json:"lastSupplierStatus,omitempty"` + PrevRequesterAction *string `json:"prevRequesterAction,omitempty"` + PrevRequesterRequestID *string `json:"prevRequesterRequestID,omitempty"` + PrevSupplierStatus *string `json:"prevSupplierStatus,omitempty"` + RequesterRequestID string `json:"requesterRequestID"` + RequesterSymbol string `json:"requesterSymbol"` + SupplierRequestID *string `json:"supplierRequestID,omitempty"` + SupplierSymbol *string `json:"supplierSymbol,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// ImportItemError defines model for ImportItemError. +type ImportItemError struct { + Error string `json:"error"` + Identifier *string `json:"identifier,omitempty"` + + // Line One-based NDJSON record number. + Line int32 `json:"line"` + Owner *string `json:"owner,omitempty"` + Type *ImportItemType `json:"type,omitempty"` +} + +// ImportItemType defines model for ImportItemType. +type ImportItemType string + +// ImportLocatedSupplier defines model for ImportLocatedSupplier. +type ImportLocatedSupplier struct { + Id string `json:"id"` + LastAction *string `json:"lastAction,omitempty"` + LastReason *string `json:"lastReason,omitempty"` + LastStatus *string `json:"lastStatus,omitempty"` + LocalID *string `json:"localID,omitempty"` + LocalSupplier bool `json:"localSupplier"` + Ordinal int32 `json:"ordinal"` + PrevAction *string `json:"prevAction,omitempty"` + PrevReason *string `json:"prevReason,omitempty"` + PrevStatus *string `json:"prevStatus,omitempty"` + SupplierRequestID *string `json:"supplierRequestID,omitempty"` + SupplierStatus *ImportLocatedSupplierSupplierStatus `json:"supplierStatus,omitempty"` + SupplierSymbol string `json:"supplierSymbol"` +} + +// ImportLocatedSupplierSupplierStatus defines model for ImportLocatedSupplier.SupplierStatus. +type ImportLocatedSupplierSupplierStatus string + +// ImportPatronRequest defines model for ImportPatronRequest. +type ImportPatronRequest struct { + CreatedAt time.Time `json:"createdAt"` + Id string `json:"id"` + + // IllRequest JSON of ISO18626 request + IllRequest iso18626.Request `json:"illRequest"` + + // IllResponse JSON of ISO18626 supplying agency message + IllResponse *iso18626.SupplyingAgencyMessage `json:"illResponse,omitempty"` + InternalNote *string `json:"internalNote,omitempty"` + LastAction *string `json:"lastAction,omitempty"` + LastActionOutcome *string `json:"lastActionOutcome,omitempty"` + LastActionResult *string `json:"lastActionResult,omitempty"` + NeedsAttention bool `json:"needsAttention"` + NextReqId *string `json:"nextReqId,omitempty"` + Patron *string `json:"patron,omitempty"` + PrevReqId *string `json:"prevReqId,omitempty"` + RequesterRequestId string `json:"requesterRequestId"` + RequesterSymbol string `json:"requesterSymbol"` + + // RetryBibInfo Bibliographic retry information as defined in ISO18626 + RetryBibInfo *iso18626.BibliographicInfo `json:"retryBibInfo,omitempty"` + Side string `json:"side"` + State string `json:"state"` + StateModel string `json:"stateModel"` + SupplierSymbol *string `json:"supplierSymbol,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ImportPatronRequestBundle defines model for ImportPatronRequestBundle. +type ImportPatronRequestBundle struct { + IllTransaction *ImportIllTransaction `json:"illTransaction,omitempty"` + Items []ImportPatronRequestItem `json:"items"` + LocatedSuppliers []ImportLocatedSupplier `json:"locatedSuppliers"` + Notifications []ImportPatronRequestNotification `json:"notifications"` + PatronRequest ImportPatronRequest `json:"patronRequest"` +} + +// ImportPatronRequestItem defines model for ImportPatronRequestItem. +type ImportPatronRequestItem struct { + Barcode string `json:"barcode"` + CallNumber *string `json:"callNumber,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Id string `json:"id"` + ItemId *string `json:"itemId,omitempty"` + LmsRequestId *string `json:"lmsRequestId,omitempty"` + Title *string `json:"title,omitempty"` +} + +// ImportPatronRequestNotification defines model for ImportPatronRequestNotification. +type ImportPatronRequestNotification struct { + AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"` + Condition *string `json:"condition,omitempty"` + Cost *float64 `json:"cost,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Currency *string `json:"currency,omitempty"` + Direction ImportPatronRequestNotificationDirection `json:"direction"` + FromSymbol string `json:"fromSymbol"` + Id string `json:"id"` + Kind ImportPatronRequestNotificationKind `json:"kind"` + Note *string `json:"note,omitempty"` + Receipt *string `json:"receipt,omitempty"` + ToSymbol string `json:"toSymbol"` +} + +// ImportPatronRequestNotificationDirection defines model for ImportPatronRequestNotification.Direction. +type ImportPatronRequestNotificationDirection string + +// ImportPatronRequestNotificationKind defines model for ImportPatronRequestNotification.Kind. +type ImportPatronRequestNotificationKind string + +// ImportResourceRecord defines model for ImportResourceRecord. +type ImportResourceRecord struct { + // Data Resource data to import. + Data ImportResourceRecord_Data `json:"data"` + + // Owner Symbol of the owning institution + Owner string `json:"owner"` + Type ImportItemType `json:"type"` +} + +// ImportResourceRecord_Data Resource data to import. +type ImportResourceRecord_Data struct { + union json.RawMessage +} + +// ImportResult defines model for ImportResult. +type ImportResult struct { + BatchActions ImportSectionResult `json:"batchActions"` + Errors []ImportItemError `json:"errors"` + PatronRequests ImportSectionResult `json:"patronRequests"` + Templates ImportSectionResult `json:"templates"` +} + +// ImportSectionResult defines model for ImportSectionResult. +type ImportSectionResult struct { + Failed int32 `json:"failed"` + Imported int32 `json:"imported"` + Skipped int32 `json:"skipped"` +} + +// Index defines model for Index. +type Index struct { + Links struct { + // BatchActionsLink Link to batch actions + BatchActionsLink string `json:"batchActionsLink"` + + // BorrowingRequestsLink Link to borrowing requests + BorrowingRequestsLink string `json:"borrowingRequestsLink"` + + // IllTransactionsLink Link to ILL transactions + IllTransactionsLink string `json:"illTransactionsLink"` + + // LendingRequestsLink Link to lending requests + LendingRequestsLink string `json:"lendingRequestsLink"` + + // PeersLink Link to peers + PeersLink string `json:"peersLink"` + } `json:"links"` + + // Revision VCS revision + Revision string `json:"revision"` + + // Signature Application signature + Signature string `json:"signature"` +} + +// LocatedSupplier defines model for LocatedSupplier. +type LocatedSupplier struct { + // Id Generate ID + Id string `json:"id"` + + // IllTransactionID Ill Transaction ID + IllTransactionID string `json:"illTransactionID"` + + // LastAction Latest requester action + LastAction *string `json:"lastAction,omitempty"` + + // LastReason Latest requester reason + LastReason *string `json:"lastReason,omitempty"` + + // LastStatus Latest supplier transaction status + LastStatus *string `json:"lastStatus,omitempty"` + + // LocalID Item local ID + LocalID *string `json:"localID,omitempty"` + + // Ordinal Ordinal number for ordering + Ordinal int32 `json:"ordinal"` + + // PrevAction Previous requester action + PrevAction *string `json:"prevAction,omitempty"` + + // PrevReason Previous requester reason + PrevReason *string `json:"prevReason,omitempty"` + + // PrevStatus Previous supplier transaction status + PrevStatus *string `json:"prevStatus,omitempty"` + + // SupplierID Supplier ID from peer table + SupplierID string `json:"supplierID"` + + // SupplierPeerLink Link to supplier Peer + SupplierPeerLink string `json:"supplierPeerLink"` + + // SupplierRequestID Supplier request ID + SupplierRequestID *string `json:"supplierRequestID,omitempty"` + + // SupplierStatus Supplier status, possible values (new, selected, skipped) + SupplierStatus *string `json:"supplierStatus,omitempty"` + + // SupplierSymbol Supplier symbol to use for communication + SupplierSymbol string `json:"supplierSymbol"` +} + +// LocatedSuppliers defines model for LocatedSuppliers. +type LocatedSuppliers struct { + About About `json:"about"` + + // Items List of peers + Items []LocatedSupplier `json:"items"` +} + +// ModelAction Declares an action available while in this state. +type ModelAction struct { + // AppliesTo Criteria determining whether a state, action, or event applies to a request. When omitted, the element applies to all requests. + AppliesTo *AppliesTo `json:"appliesTo,omitempty"` + + // Desc Description of the action + Desc *string `json:"desc,omitempty"` + + // Name Name of the action + Name string `json:"name"` + + // Params Parameters for the action. The presence of parameters indicates that the action requires additional input to be executed. + Params *ModelAction_Params `json:"params,omitempty"` + + // PrimaryFor Makes this the primary action for the selected service types. At most one applicable action may be primary for a service type in a state. + PrimaryFor *AppliesTo `json:"primaryFor,omitempty"` + + // Transitions Action outcome to state transition mapping. When no transition is defined, the action is considered to be non-state-changing. + Transitions *struct { + // Failure Target state when the action fails. + Failure *string `json:"failure,omitempty"` + + // Review Target state when the action requires review. + Review *string `json:"review,omitempty"` + + // Success Target state when the action succeeds. + Success *string `json:"success,omitempty"` + } `json:"transitions,omitempty"` + + // Trigger Trigger for the action + Trigger *ModelActionTrigger `json:"trigger,omitempty"` +} + +// ModelActionParamsSendTo defines model for ModelAction.Params.SendTo. +type ModelActionParamsSendTo string + +// ModelAction_Params Parameters for the action. The presence of parameters indicates that the action requires additional input to be executed. +type ModelAction_Params struct { + // SendTo Notification recipients for this action. + SendTo *[]ModelActionParamsSendTo `json:"sendTo,omitempty"` + + // TemplateLabel Template label for this action. The template selector is used to select the appropriate template for generating messages. + TemplateLabel *string `json:"templateLabel,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// ModelActionTrigger Trigger for the action +type ModelActionTrigger string + +// ModelEvent Declares an event considered for transition while in this state. +type ModelEvent struct { + // AppliesTo Criteria determining whether a state, action, or event applies to a request. When omitted, the element applies to all requests. + AppliesTo *AppliesTo `json:"appliesTo,omitempty"` + + // Desc Description of the event + Desc *string `json:"desc,omitempty"` + + // Name Name of the event + Name string `json:"name"` + + // Transition State transition after event has occurred. When no transition is defined, the event is considered to be non-state-changing. + Transition *string `json:"transition,omitempty"` +} + +// ModelState Definition of a particular state +type ModelState struct { + // Actions List of all actions that may be performed on the request when in this state + Actions *[]ModelAction `json:"actions,omitempty"` + + // AppliesTo Criteria determining whether a state, action, or event applies to a request. When omitted, the element applies to all requests. + AppliesTo *AppliesTo `json:"appliesTo,omitempty"` + + // ClosingAction Name of the closing action for this state. Must be one of the actions defined in the actions array. + ClosingAction *string `json:"closingAction,omitempty"` + + // Desc Description of the state, e.g., what situation is modelled by the state + Desc *string `json:"desc,omitempty"` + + // Display Human-readable state name + Display *string `json:"display,omitempty"` + + // Editable Indicates that a patron request can be updated in this state + Editable *bool `json:"editable,omitempty"` + + // Events List of all events that may be triggered to the request when in this state + Events *[]ModelEvent `json:"events,omitempty"` + + // Initial Indicates if the state is the initial state of the request + Initial *bool `json:"initial,omitempty"` + + // ManualClose Indicates that this terminal state is the target for manual termination + ManualClose *bool `json:"manualClose,omitempty"` + + // Name Name of the state, in capital letters with underscores + Name string `json:"name"` + + // NeedsAttention Indicates that this state requires user attention (e.g. displayed as a flag in the UI) + NeedsAttention *bool `json:"needsAttention,omitempty"` + + // PrimaryAction Name of the unconditional primary action for this state. Must be one of the actions defined in the actions array. Use primaryFor on individual actions when the primary action varies by service type. + PrimaryAction *string `json:"primaryAction,omitempty"` + + // Side Indicates which side of the request the state belongs to + Side ModelStateSide `json:"side"` + + // Terminal Indicates if the state is terminal (meaning no actions or events are allowed in this state) + Terminal *bool `json:"terminal,omitempty"` + union json.RawMessage +} + +// ModelStateSide Indicates which side of the request the state belongs to +type ModelStateSide string + +// ModelState0 defines model for ModelState.0. +type ModelState0 struct { + union json.RawMessage +} + +// ModelState00 defines model for ModelState.0.0. +type ModelState00 = interface{} + +// ModelState01 defines model for ModelState.0.1. +type ModelState01 = interface{} + +// ModelState1 defines model for ModelState.1. +type ModelState1 = interface{} + +// PatronRequest defines model for PatronRequest. +type PatronRequest struct { + // AvailableActionsLink Link to available actions for this patron request + AvailableActionsLink *string `json:"availableActionsLink,omitempty"` + + // CreatedAt Timestamp of the patron request creation + CreatedAt time.Time `json:"createdAt"` + + // EventsLink Link to events for this patron request + EventsLink *string `json:"eventsLink,omitempty"` + + // HasCost Indicates if the request has a cost notification + HasCost bool `json:"hasCost"` + + // Id Unique identifier of the patron request + Id string `json:"id"` + + // IllRequest JSON of ISO18626 request + IllRequest map[string]interface{} `json:"illRequest"` + + // IllResponse JSON of ISO18626 supplying agency message + IllResponse *map[string]interface{} `json:"illResponse,omitempty"` + + // IllTransactionLink Link to related ILL transaction lookup + IllTransactionLink *string `json:"illTransactionLink,omitempty"` + + // InternalNote Staff-only internal note, local to this request and never shared with peers + InternalNote *string `json:"internalNote,omitempty"` + + // Items List of patron request items + Items *[]PrItem `json:"items,omitempty"` + + // ItemsLink Link to items for this patron request + ItemsLink *string `json:"itemsLink,omitempty"` + + // LastAction Latest action on this request + LastAction *string `json:"lastAction,omitempty"` + + // LastActionOutcome Latest action outcome + LastActionOutcome *string `json:"lastActionOutcome,omitempty"` + + // LastActionResult Latest action status ("NEW", "PROCESSING", "SUCCESS", "PROBLEM", "ERROR") + LastActionResult *string `json:"lastActionResult,omitempty"` + + // NeedsAttention Indicates if the request needs attention + NeedsAttention bool `json:"needsAttention"` + + // NextReqId ID of the next patron request in the sequence + NextReqId *string `json:"nextReqId,omitempty"` + + // NotificationsLink Link to notifications for this patron request + NotificationsLink *string `json:"notificationsLink,omitempty"` + + // Patron User who requested item + Patron *string `json:"patron,omitempty"` + + // PrevReqId ID of the previous patron request in the sequence + PrevReqId *string `json:"prevReqId,omitempty"` + + // RequesterName Name of the requester peer + RequesterName *string `json:"requesterName,omitempty"` + + // RequesterRequestId Requester patron request ID + RequesterRequestId *string `json:"requesterRequestId,omitempty"` + + // RequesterSymbol Requester symbol + RequesterSymbol *string `json:"requesterSymbol,omitempty"` + + // RetryBibInfo Bibliographic information for an ILL request item, as defined in ISO18626 + RetryBibInfo *BibliographicInfo `json:"retryBibInfo,omitempty"` + + // Side Patron request side - borrowing or lending + Side string `json:"side"` + + // State Patron request state + State string `json:"state"` + + // StateModel State model configuration key governing this request + StateModel string `json:"stateModel"` + + // SupplierName Name of the supplier peer + SupplierName *string `json:"supplierName,omitempty"` + + // SupplierSymbol Supplier symbol + SupplierSymbol *string `json:"supplierSymbol,omitempty"` + + // TerminalState Indicates if the request is in terminal state + TerminalState bool `json:"terminalState"` + + // UnreadNotificationsCount Number of unread notifications for this request + UnreadNotificationsCount int64 `json:"unreadNotificationsCount"` + + // UpdatedAt Timestamp of the patron request last update + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// PatronRequests defines model for PatronRequests. +type PatronRequests struct { + About AboutWithFacets `json:"about"` + + // Items List of patron request + Items []PatronRequest `json:"items"` +} + +// Peer defines model for Peer. +type Peer struct { + // BorrowsCount Count of borrows + BorrowsCount *int32 `json:"borrowsCount,omitempty"` + + // BranchSymbols Symbols of peer branches + BranchSymbols *[]string `json:"branchSymbols,omitempty"` + + // BrokerMode Broker mode, e.g "opaque", "transparent" or "translucent" + BrokerMode PeerBrokerMode `json:"brokerMode"` + + // CustomData Custom data of peer + CustomData *dirapi.Entry `json:"customData,omitempty"` + + // HttpHeaders HTTP headers to be sent with requests to the peer + HttpHeaders *map[string]string `json:"httpHeaders,omitempty"` + + // Id Unique identifier for the peer + Id string `json:"id"` + + // LoansCount Count of loans + LoansCount *int32 `json:"loansCount,omitempty"` + + // Name Name of the peer + Name string `json:"name"` + + // RefreshPolicy Policy for refreshing peer information (never, transaction) + RefreshPolicy PeerRefreshPolicy `json:"refreshPolicy"` + + // RefreshTime Timestamp of refresh + RefreshTime *time.Time `json:"refreshTime,omitempty"` + + // Symbols Unique symbol representing the peer + Symbols []string `json:"symbols"` + + // Url Network URL of the peer + Url string `json:"url"` + + // Vendor Vendor of the ISO18626 implementation, e.g "Alma", "ReShare" + Vendor string `json:"vendor"` +} + +// PeerBrokerMode Broker mode, e.g "opaque", "transparent" or "translucent" +type PeerBrokerMode string + +// PeerRefreshPolicy Policy for refreshing peer information (never, transaction) +type PeerRefreshPolicy string + +// Peers defines model for Peers. +type Peers struct { + About About `json:"about"` + + // Items List of peers + Items []Peer `json:"items"` +} + +// PrItem Patron request item +type PrItem struct { + // Barcode Item barcode + Barcode string `json:"barcode"` + + // CallNumber Item call number + CallNumber *string `json:"callNumber,omitempty"` + + // CreatedAt Item creation date time + CreatedAt time.Time `json:"createdAt"` + + // Id Item system id + Id string `json:"id"` + + // ItemId Item item id + ItemId *string `json:"itemId,omitempty"` + + // Title Item title + Title *string `json:"title,omitempty"` +} + +// PrItems defines model for PrItems. +type PrItems struct { + About About `json:"about"` + + // Items List of patron request items + Items []PrItem `json:"items"` +} + +// PrNotification Patron request notification +type PrNotification struct { + // AcknowledgedAt Notification acknowledged at date time + AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"` + + // Condition Condition of notification + Condition *string `json:"condition,omitempty"` + + // Cost Cost amount + Cost *float64 `json:"cost,omitempty"` + + // CreatedAt Notification creation date time + CreatedAt time.Time `json:"createdAt"` + + // Currency Currency symbol + Currency *string `json:"currency,omitempty"` + + // Direction Direction of the notification, either sent or received + Direction string `json:"direction"` + + // FromSymbol Symbol of notification sender + FromSymbol string `json:"fromSymbol"` + + // Id Notification id + Id string `json:"id"` + + // Kind Kind of notification, either note or condition + Kind PrNotificationKind `json:"kind"` + + // Note Note of notification + Note *string `json:"note,omitempty"` + + // Receipt Receipt of notification + Receipt *string `json:"receipt,omitempty"` + + // ToSymbol Symbol of notification receiver + ToSymbol string `json:"toSymbol"` +} + +// PrNotificationKind Kind of notification, either note or condition +type PrNotificationKind string + +// PrNotifications defines model for PrNotifications. +type PrNotifications struct { + About About `json:"about"` + + // Items List of patron request notifications + Items []PrNotification `json:"items"` +} + +// PullSlip A generated pull slip record +type PullSlip struct { + // CreatedAt Creation timestamp + CreatedAt time.Time `json:"createdAt"` + + // GeneratedAt When the PDF was generated + GeneratedAt *time.Time `json:"generatedAt,omitempty"` + + // Id Pull slip ID + Id string `json:"id"` + + // Owner Symbol of the owning institution + Owner string `json:"owner"` + + // PdfLink Link to download the PDF + PdfLink *string `json:"pdfLink,omitempty"` + + // SearchCriteria Criteria used to generate this pull slip + SearchCriteria string `json:"searchCriteria"` + + // Type Pull slip type (single, batch) + Type PullSlipType `json:"type"` +} + +// PullSlipType Pull slip type (single, batch) +type PullSlipType string + +// SseResult defines model for SseResult. +type SseResult struct { + // Data Data of the event, for example ISO18626 message + Data map[string]interface{} `json:"data"` + + // Event Event name, for example 'message-supplier' + Event string `json:"event"` +} + +// StateModel ReShare state model definition +type StateModel struct { + // Desc Description of the state model + Desc *string `json:"desc,omitempty"` + + // Name Name of the state model + Name string `json:"name"` + + // PullslipPdfTemplateLabel Template label used to resolve the PDF pullslip template for this state model. + PullslipPdfTemplateLabel *string `json:"pullslipPdfTemplateLabel,omitempty"` + + // Selector Criteria used to select this state model for an ISO 18626 request + Selector *StateModelSelector `json:"selector,omitempty"` + + // States A list of all allowed states + States []ModelState `json:"states"` + + // Type Self-referential type + Type StateModelType `json:"type"` + + // Version Version of the state model in SemVer + Version string `json:"version"` +} + +// StateModelType Self-referential type +type StateModelType string + +// StateModelCapabilities Built-in states, actions and message events supported by the broker +type StateModelCapabilities struct { + RequesterActions []ActionCapability `json:"requesterActions"` + RequesterMessageEvents []string `json:"requesterMessageEvents"` + RequesterStates []string `json:"requesterStates"` + SupplierActions []ActionCapability `json:"supplierActions"` + SupplierMessageEvents []string `json:"supplierMessageEvents"` + SupplierStates []string `json:"supplierStates"` +} + +// StateModelSelector Criteria used to select this state model for an ISO 18626 request +type StateModelSelector struct { + // ServiceType ISO 18626 service types handled by this state model + ServiceType []StateModelServiceType `json:"serviceType"` +} + +// StateModelServiceType ISO 18626 service type used by state-model selection and applicability rules +type StateModelServiceType string + +// StatusMessage defines model for StatusMessage. +type StatusMessage struct { + // Status Process status + Status string `json:"status"` +} + +// Template An email or pull slip template with optional placeholder support +type Template struct { + // Audience Intended audience for the template. Omit to apply to both patron and staff. + Audience *TemplateAudience `json:"audience,omitempty"` + + // Body Body of the email or pull slip template. Supports {{.X}} placeholders. Supported placeholders for patron request templates include {{.ReqId}}, {{.PickupLocation}}, {{.Title}}, {{.Author}}, {{.DueDate}}, {{.ReturnAddress}}, {{.BarcodeBase64}}, {{.ServiceType}}, {{.ServiceLevel}}, {{.SystemIdentifier}}, {{.Publisher}}, {{.Volume}}, {{.Issue}}, {{.Pages}}, {{.StaffNotes}}, {{.CallNumber}}, {{.LoanConditions}}, {{.PatronName}}, {{.PatronSurname}}, {{.PatronId}} and batch templates include {{.FullCount}}, {{.ActualCount}}, {{.BatchQuery}}. + Body string `json:"body"` + + // ContentType Output content type for the template body + ContentType TemplateContentType `json:"contentType"` + + // CreatedAt Timestamp when the template was created + CreatedAt time.Time `json:"createdAt"` + + // Id Unique identifier of the template + Id string `json:"id"` + + // Labels Predefined labels identifying the template's usage context. Values correspond to templateLabel parameters used in state model send-email actions. + Labels []string `json:"labels"` + + // Purpose Purpose of the template + Purpose TemplatePurpose `json:"purpose"` + + // Subject Subject line template, supports {{.X}} placeholders. Not used for pullslip templates. Supports same placeholders as template body. + Subject *string `json:"subject,omitempty"` + + // Title Human-readable title for the template + Title string `json:"title"` + + // UpdatedAt Timestamp when the template was last updated + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +// TemplateAudience Intended audience for the template. Omit to apply to both patron and staff. +type TemplateAudience string + +// TemplateContentType Output content type for the template body +type TemplateContentType string + +// TemplatePurpose Purpose of the template +type TemplatePurpose string + +// Templates defines model for Templates. +type Templates struct { + About About `json:"about"` + + // Items List of templates + Items []Template `json:"items"` +} + +// UpdateBatchAction Request body for updating a batch action +type UpdateBatchAction struct { + // ActionParams Parameters for the batch action + ActionParams *map[string]interface{} `json:"actionParams,omitempty"` + + // BatchQuery Batch action selection query in CQL format + BatchQuery string `json:"batchQuery"` + + // Schedule RRULE schedule expression, e.g. "FREQ=WEEKLY;BYDAY=MO;BYHOUR=6;BYMINUTE=0" (every Monday at 06:00) + Schedule string `json:"schedule"` + + // Title Title of the batch action, for display purposes + Title *string `json:"title,omitempty"` +} + +// UpdateInternalNote defines model for UpdateInternalNote. +type UpdateInternalNote struct { + // InternalNote Staff-only internal note text. Send an empty string or omit to clear the note. + InternalNote *string `json:"internalNote,omitempty"` +} + +// UpdateNotificationReceipt Update notification receipt body +type UpdateNotificationReceipt struct { + // Receipt Notification receipt status + Receipt UpdateNotificationReceiptReceipt `json:"receipt"` +} + +// UpdateNotificationReceiptReceipt Notification receipt status +type UpdateNotificationReceiptReceipt string + +// UpdateTemplate Request body for replacing a template's mutable fields. +type UpdateTemplate struct { + // Audience Intended audience for the template. Omit to apply to both patron and staff. + Audience *TemplateAudience `json:"audience,omitempty"` + + // Body Body of the email or pull slip template. Supports {{.X}} placeholders. For full list of supported placeholders, see the Template object. + Body string `json:"body"` + + // ContentType Output content type for the template body + ContentType TemplateContentType `json:"contentType"` + + // Labels Labels identifying the template's usage context + Labels []string `json:"labels"` + + // Subject Subject line template supporting {{.X}} placeholders. Not used for pull slip templates. Omit to clear. For full list of supported placeholders, see the Template object. + Subject *string `json:"subject,omitempty"` + + // Title Human-readable title for the template + Title string `json:"title"` +} + +// ArchiveDelay defines model for ArchiveDelay. +type ArchiveDelay = string + +// ArchiveStatus defines model for ArchiveStatus. +type ArchiveStatus = string + +// Cql defines model for Cql. +type Cql = string + +// Facets defines model for Facets. +type Facets = []string + +// IllTransactionId defines model for IllTransactionId. +type IllTransactionId = string + +// Limit defines model for Limit. +type Limit = int32 + +// NotificationKind defines model for NotificationKind. +type NotificationKind string + +// Offset defines model for Offset. +type Offset = int32 + +// PatronRequestId defines model for PatronRequestId. +type PatronRequestId = string + +// RequesterRequestId defines model for RequesterRequestId. +type RequesterRequestId = string + +// RequesterSymbol defines model for RequesterSymbol. +type RequesterSymbol = string + +// Side defines model for Side. +type Side = string + +// Symbol defines model for Symbol. +type Symbol = string + +// Tenant defines model for Tenant. +type Tenant = string + +// PostImportParams defines parameters for PostImport. +type PostImportParams struct { + // ConflictPolicy How to handle an existing resource identity. + ConflictPolicy *ConflictPolicy `form:"conflictPolicy,omitempty" json:"conflictPolicy,omitempty"` +} + +// Getter for additional properties for ModelAction_Params. Returns the specified +// element and whether it was found +func (a ModelAction_Params) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for ModelAction_Params +func (a *ModelAction_Params) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for ModelAction_Params to handle AdditionalProperties +func (a *ModelAction_Params) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["sendTo"]; found { + err = json.Unmarshal(raw, &a.SendTo) + if err != nil { + return fmt.Errorf("error reading 'sendTo': %w", err) + } + delete(object, "sendTo") + } + + if raw, found := object["templateLabel"]; found { + err = json.Unmarshal(raw, &a.TemplateLabel) + if err != nil { + return fmt.Errorf("error reading 'templateLabel': %w", err) + } + delete(object, "templateLabel") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for ModelAction_Params to handle AdditionalProperties +func (a ModelAction_Params) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.SendTo != nil { + object["sendTo"], err = json.Marshal(a.SendTo) + if err != nil { + return nil, fmt.Errorf("error marshaling 'sendTo': %w", err) + } + } + + if a.TemplateLabel != nil { + object["templateLabel"], err = json.Marshal(a.TemplateLabel) + if err != nil { + return nil, fmt.Errorf("error marshaling 'templateLabel': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// AsImportPatronRequestBundle returns the union data inside the ImportResourceRecord_Data as a ImportPatronRequestBundle +func (t ImportResourceRecord_Data) AsImportPatronRequestBundle() (ImportPatronRequestBundle, error) { + var body ImportPatronRequestBundle + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromImportPatronRequestBundle overwrites any union data inside the ImportResourceRecord_Data as the provided ImportPatronRequestBundle +func (t *ImportResourceRecord_Data) FromImportPatronRequestBundle(v ImportPatronRequestBundle) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeImportPatronRequestBundle performs a merge with any union data inside the ImportResourceRecord_Data, using the provided ImportPatronRequestBundle +func (t *ImportResourceRecord_Data) MergeImportPatronRequestBundle(v ImportPatronRequestBundle) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsCreateBatchAction returns the union data inside the ImportResourceRecord_Data as a CreateBatchAction +func (t ImportResourceRecord_Data) AsCreateBatchAction() (CreateBatchAction, error) { + var body CreateBatchAction + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCreateBatchAction overwrites any union data inside the ImportResourceRecord_Data as the provided CreateBatchAction +func (t *ImportResourceRecord_Data) FromCreateBatchAction(v CreateBatchAction) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCreateBatchAction performs a merge with any union data inside the ImportResourceRecord_Data, using the provided CreateBatchAction +func (t *ImportResourceRecord_Data) MergeCreateBatchAction(v CreateBatchAction) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsCreateTemplate returns the union data inside the ImportResourceRecord_Data as a CreateTemplate +func (t ImportResourceRecord_Data) AsCreateTemplate() (CreateTemplate, error) { + var body CreateTemplate + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCreateTemplate overwrites any union data inside the ImportResourceRecord_Data as the provided CreateTemplate +func (t *ImportResourceRecord_Data) FromCreateTemplate(v CreateTemplate) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCreateTemplate performs a merge with any union data inside the ImportResourceRecord_Data, using the provided CreateTemplate +func (t *ImportResourceRecord_Data) MergeCreateTemplate(v CreateTemplate) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t ImportResourceRecord_Data) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *ImportResourceRecord_Data) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsModelState0 returns the union data inside the ModelState as a ModelState0 +func (t ModelState) AsModelState0() (ModelState0, error) { + var body ModelState0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromModelState0 overwrites any union data inside the ModelState as the provided ModelState0 +func (t *ModelState) FromModelState0(v ModelState0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeModelState0 performs a merge with any union data inside the ModelState, using the provided ModelState0 +func (t *ModelState) MergeModelState0(v ModelState0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsModelState1 returns the union data inside the ModelState as a ModelState1 +func (t ModelState) AsModelState1() (ModelState1, error) { + var body ModelState1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromModelState1 overwrites any union data inside the ModelState as the provided ModelState1 +func (t *ModelState) FromModelState1(v ModelState1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeModelState1 performs a merge with any union data inside the ModelState, using the provided ModelState1 +func (t *ModelState) MergeModelState1(v ModelState1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t ModelState) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + if err != nil { + return nil, err + } + object := make(map[string]json.RawMessage) + if t.union != nil { + err = json.Unmarshal(b, &object) + if err != nil { + return nil, err + } + } + + if t.Actions != nil { + object["actions"], err = json.Marshal(t.Actions) + if err != nil { + return nil, fmt.Errorf("error marshaling 'actions': %w", err) + } + } + + if t.AppliesTo != nil { + object["appliesTo"], err = json.Marshal(t.AppliesTo) + if err != nil { + return nil, fmt.Errorf("error marshaling 'appliesTo': %w", err) + } + } + + if t.ClosingAction != nil { + object["closingAction"], err = json.Marshal(t.ClosingAction) + if err != nil { + return nil, fmt.Errorf("error marshaling 'closingAction': %w", err) + } + } + + if t.Desc != nil { + object["desc"], err = json.Marshal(t.Desc) + if err != nil { + return nil, fmt.Errorf("error marshaling 'desc': %w", err) + } + } + + if t.Display != nil { + object["display"], err = json.Marshal(t.Display) + if err != nil { + return nil, fmt.Errorf("error marshaling 'display': %w", err) + } + } + + if t.Editable != nil { + object["editable"], err = json.Marshal(t.Editable) + if err != nil { + return nil, fmt.Errorf("error marshaling 'editable': %w", err) + } + } + + if t.Events != nil { + object["events"], err = json.Marshal(t.Events) + if err != nil { + return nil, fmt.Errorf("error marshaling 'events': %w", err) + } + } + + if t.Initial != nil { + object["initial"], err = json.Marshal(t.Initial) + if err != nil { + return nil, fmt.Errorf("error marshaling 'initial': %w", err) + } + } + + if t.ManualClose != nil { + object["manualClose"], err = json.Marshal(t.ManualClose) + if err != nil { + return nil, fmt.Errorf("error marshaling 'manualClose': %w", err) + } + } + + object["name"], err = json.Marshal(t.Name) + if err != nil { + return nil, fmt.Errorf("error marshaling 'name': %w", err) + } + + if t.NeedsAttention != nil { + object["needsAttention"], err = json.Marshal(t.NeedsAttention) + if err != nil { + return nil, fmt.Errorf("error marshaling 'needsAttention': %w", err) + } + } + + if t.PrimaryAction != nil { + object["primaryAction"], err = json.Marshal(t.PrimaryAction) + if err != nil { + return nil, fmt.Errorf("error marshaling 'primaryAction': %w", err) + } + } + + object["side"], err = json.Marshal(t.Side) + if err != nil { + return nil, fmt.Errorf("error marshaling 'side': %w", err) + } + + if t.Terminal != nil { + object["terminal"], err = json.Marshal(t.Terminal) + if err != nil { + return nil, fmt.Errorf("error marshaling 'terminal': %w", err) + } + } + b, err = json.Marshal(object) + return b, err +} + +func (t *ModelState) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + if err != nil { + return err + } + object := make(map[string]json.RawMessage) + err = json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["actions"]; found { + err = json.Unmarshal(raw, &t.Actions) + if err != nil { + return fmt.Errorf("error reading 'actions': %w", err) + } + } + + if raw, found := object["appliesTo"]; found { + err = json.Unmarshal(raw, &t.AppliesTo) + if err != nil { + return fmt.Errorf("error reading 'appliesTo': %w", err) + } + } + + if raw, found := object["closingAction"]; found { + err = json.Unmarshal(raw, &t.ClosingAction) + if err != nil { + return fmt.Errorf("error reading 'closingAction': %w", err) + } + } + + if raw, found := object["desc"]; found { + err = json.Unmarshal(raw, &t.Desc) + if err != nil { + return fmt.Errorf("error reading 'desc': %w", err) + } + } + + if raw, found := object["display"]; found { + err = json.Unmarshal(raw, &t.Display) + if err != nil { + return fmt.Errorf("error reading 'display': %w", err) + } + } + + if raw, found := object["editable"]; found { + err = json.Unmarshal(raw, &t.Editable) + if err != nil { + return fmt.Errorf("error reading 'editable': %w", err) + } + } + + if raw, found := object["events"]; found { + err = json.Unmarshal(raw, &t.Events) + if err != nil { + return fmt.Errorf("error reading 'events': %w", err) + } + } + + if raw, found := object["initial"]; found { + err = json.Unmarshal(raw, &t.Initial) + if err != nil { + return fmt.Errorf("error reading 'initial': %w", err) + } + } + + if raw, found := object["manualClose"]; found { + err = json.Unmarshal(raw, &t.ManualClose) + if err != nil { + return fmt.Errorf("error reading 'manualClose': %w", err) + } + } + + if raw, found := object["name"]; found { + err = json.Unmarshal(raw, &t.Name) + if err != nil { + return fmt.Errorf("error reading 'name': %w", err) + } + } + + if raw, found := object["needsAttention"]; found { + err = json.Unmarshal(raw, &t.NeedsAttention) + if err != nil { + return fmt.Errorf("error reading 'needsAttention': %w", err) + } + } + + if raw, found := object["primaryAction"]; found { + err = json.Unmarshal(raw, &t.PrimaryAction) + if err != nil { + return fmt.Errorf("error reading 'primaryAction': %w", err) + } + } + + if raw, found := object["side"]; found { + err = json.Unmarshal(raw, &t.Side) + if err != nil { + return fmt.Errorf("error reading 'side': %w", err) + } + } + + if raw, found := object["terminal"]; found { + err = json.Unmarshal(raw, &t.Terminal) + if err != nil { + return fmt.Errorf("error reading 'terminal': %w", err) + } + } + + return err +} + +// AsModelState00 returns the union data inside the ModelState0 as a ModelState00 +func (t ModelState0) AsModelState00() (ModelState00, error) { + var body ModelState00 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromModelState00 overwrites any union data inside the ModelState0 as the provided ModelState00 +func (t *ModelState0) FromModelState00(v ModelState00) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeModelState00 performs a merge with any union data inside the ModelState0, using the provided ModelState00 +func (t *ModelState0) MergeModelState00(v ModelState00) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsModelState01 returns the union data inside the ModelState0 as a ModelState01 +func (t ModelState0) AsModelState01() (ModelState01, error) { + var body ModelState01 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromModelState01 overwrites any union data inside the ModelState0 as the provided ModelState01 +func (t *ModelState0) FromModelState01(v ModelState01) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeModelState01 performs a merge with any union data inside the ModelState0, using the provided ModelState01 +func (t *ModelState0) MergeModelState01(v ModelState01) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t ModelState0) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *ModelState0) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // PostImport Import resources from NDJSON + // (POST /import) + PostImport(w http.ResponseWriter, r *http.Request, params PostImportParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// PostImport operation middleware +func (siw *ServerInterfaceWrapper) PostImport(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params PostImportParams + + // ------------- Optional query parameter "conflictPolicy" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "conflictPolicy", r.URL.Query(), ¶ms.ConflictPolicy, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "conflictPolicy"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "conflictPolicy", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostImport(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/import", wrapper.PostImport) + + return m +} diff --git a/broker/import/service/importer.go b/broker/import/service/importer.go new file mode 100644 index 00000000..6095022c --- /dev/null +++ b/broker/import/service/importer.go @@ -0,0 +1,574 @@ +package service + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/indexdata/crosslink/broker/adapter" + "github.com/indexdata/crosslink/broker/common" + "github.com/indexdata/crosslink/broker/events" + ill_db "github.com/indexdata/crosslink/broker/ill_db" + importdb "github.com/indexdata/crosslink/broker/import/db" + importoapi "github.com/indexdata/crosslink/broker/import/oapi" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/broker/patron_request/proapi" + prservice "github.com/indexdata/crosslink/broker/patron_request/service" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" + schedoapi "github.com/indexdata/crosslink/broker/scheduler/oapi" + schedservice "github.com/indexdata/crosslink/broker/scheduler/service" + "github.com/indexdata/crosslink/iso18626" + "github.com/jackc/pgx/v5/pgtype" +) + +const ( + importItemTypePatronRequest = "patronRequest" + importItemTypeBatchAction = "batchAction" + importItemTypeTemplate = "template" + maxImportRecordBytes = 1 << 20 +) + +var ErrImportRecordTooLarge = errors.New("import record too large") + +type importStateValidator interface { + ValidateImportState(string, proapi.StateModelServiceType, pr_db.PatronRequestSide, pr_db.PatronRequestState) (bool, error) +} + +type importPeerCache interface { + GetCachedPeersBySymbols(common.ExtendedContext, []string, adapter.DirectoryLookupAdapter) ([]ill_db.Peer, string, error) +} + +type Importer struct { + repo importdb.ImportRepo + peerCache importPeerCache + directoryAdapter adapter.DirectoryLookupAdapter + stateValidator importStateValidator + clock func() time.Time + maxRecordBytes int +} + +type importItem struct { + Type string + Owner string + Data json.RawMessage +} + +func NewImporter(repo importdb.ImportRepo, illRepo ill_db.IllRepo, directoryAdapter adapter.DirectoryLookupAdapter, stateValidator importStateValidator, clock func() time.Time) Importer { + return newImporter(repo, illRepo, directoryAdapter, stateValidator, clock) +} + +func newImporter(repo importdb.ImportRepo, peerCache importPeerCache, directoryAdapter adapter.DirectoryLookupAdapter, stateValidator importStateValidator, clock func() time.Time) Importer { + if clock == nil { + clock = time.Now + } + return Importer{ + repo: repo, + peerCache: peerCache, + directoryAdapter: directoryAdapter, + stateValidator: stateValidator, + clock: clock, + maxRecordBytes: maxImportRecordBytes, + } +} + +func decodeImportItem(raw json.RawMessage) (importItem, error) { + var envelope importItem + if err := json.Unmarshal(raw, &envelope); err != nil { + return importItem{}, err + } + if envelope.Type == "" { + return envelope, errors.New("type is required") + } + if !validImportItemType(envelope.Type) { + return envelope, fmt.Errorf("unknown type: %s", envelope.Type) + } + if envelope.Data == nil { + return envelope, errors.New("data is required") + } + trimmed := bytes.TrimSpace(envelope.Data) + if len(trimmed) == 0 { + return envelope, errors.New("data is required") + } + if trimmed[0] != '{' { + return envelope, errors.New("data must be an object") + } + return envelope, nil +} + +func (i Importer) Import(ctx common.ExtendedContext, policy importdb.ConflictPolicy, input io.Reader) (importoapi.ImportResult, error) { + result := importoapi.ImportResult{Errors: make([]importoapi.ImportItemError, 0)} + maxRecordBytes := i.maxRecordBytes + if maxRecordBytes <= 0 { + maxRecordBytes = maxImportRecordBytes + } + reader := bufio.NewReaderSize(input, maxRecordBytes+2) + for line := int32(1); ; { + raw, err := readImportRecord(reader, maxRecordBytes) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return result, err + } + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + + item, err := decodeImportItem(raw) + if err != nil { + var syntaxError *json.SyntaxError + if errors.As(err, &syntaxError) || errors.Is(err, io.ErrUnexpectedEOF) { + result.Errors = append(result.Errors, importoapi.ImportItemError{Line: line, Error: err.Error()}) + break + } + addImportFailure(&result, line, item.Type, err, &item.Owner, nil) + line++ + continue + } + identifier, repoResult, err := i.importItem(ctx, policy, item) + if err != nil { + addImportFailure(&result, line, item.Type, err, &item.Owner, identifier) + line++ + continue + } + switch repoResult.Outcome { + case importdb.OutcomeImported: + addImportSuccess(&result, item.Type) + case importdb.OutcomeSkipped: + addImportSkipped(&result, line, item.Type, repoResult.Diagnostic, &item.Owner, identifier) + default: + addImportFailure(&result, line, item.Type, fmt.Errorf("repository returned unknown outcome %q", repoResult.Outcome), &item.Owner, identifier) + } + line++ + } + return result, nil +} + +func readImportRecord(reader *bufio.Reader, maxBytes int) (json.RawMessage, error) { + record, err := reader.ReadSlice('\n') + if errors.Is(err, bufio.ErrBufferFull) { + return nil, ErrImportRecordTooLarge + } + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + if len(record) == 0 && errors.Is(err, io.EOF) { + return nil, io.EOF + } + if record[len(record)-1] == '\n' { + record = record[:len(record)-1] + if len(record) > 0 && record[len(record)-1] == '\r' { + record = record[:len(record)-1] + } + } + if len(record) > maxBytes { + return nil, ErrImportRecordTooLarge + } + return json.RawMessage(record), nil +} + +func (i Importer) importItem(ctx common.ExtendedContext, policy importdb.ConflictPolicy, item importItem) (*string, importdb.Result, error) { + switch item.Type { + case importItemTypePatronRequest: + return i.importPatronRequest(ctx, policy, item.Owner, item.Data) + case importItemTypeBatchAction: + return i.importBatchAction(ctx, policy, item.Owner, item.Data) + case importItemTypeTemplate: + return i.importTemplate(ctx, policy, item.Owner, item.Data) + default: + return nil, importdb.Result{}, fmt.Errorf("unknown type: %s", item.Type) + } +} + +func (i Importer) importPatronRequest(ctx common.ExtendedContext, policy importdb.ConflictPolicy, owner string, data json.RawMessage) (*string, importdb.Result, error) { + if i.repo == nil { + return nil, importdb.Result{}, errors.New("import repository is required") + } + err := i.validateOwner(ctx, owner) + if err != nil { + return nil, importdb.Result{}, fmt.Errorf("validate owner: %w", err) + } + var apiBundle importoapi.ImportPatronRequestBundle + if err = json.Unmarshal(data, &apiBundle); err != nil { + return nil, importdb.Result{}, err + } + var requiredFields struct { + IllTransaction *struct { + IllTransactionData json.RawMessage `json:"illTransactionData"` + } `json:"illTransaction"` + } + if err := json.Unmarshal(data, &requiredFields); err != nil { + return nil, importdb.Result{}, err + } + if requiredFields.IllTransaction != nil { + transactionData := bytes.TrimSpace(requiredFields.IllTransaction.IllTransactionData) + if len(transactionData) == 0 || bytes.Equal(transactionData, []byte("null")) { + return nil, importdb.Result{}, errors.New("illTransaction.illTransactionData is required") + } + } + identifier := stringPtr(apiBundle.PatronRequest.Id) + bundle, symbols, err := i.normalizePatronRequest(owner, apiBundle) + if err != nil { + return identifier, importdb.Result{}, err + } + if bundle.IllTransaction != nil { + if i.peerCache == nil { + return identifier, importdb.Result{}, errors.New("ILL peer cache is required") + } + peers, _, err := i.peerCache.GetCachedPeersBySymbols(ctx, symbols, i.directoryAdapter) + if err != nil { + return identifier, importdb.Result{}, fmt.Errorf("cache required peers: %w", err) + } + if len(peers) != len(symbols) { + return identifier, importdb.Result{}, fmt.Errorf("cache required peers: expected %d peers, got %d", len(symbols), len(peers)) + } + peerIDs := make(map[string]string, len(symbols)) + for index, symbol := range symbols { + if peers[index].ID == "" { + return identifier, importdb.Result{}, fmt.Errorf("cache required peers: symbol %q resolved to an empty peer ID", symbol) + } + peerIDs[symbol] = peers[index].ID + } + bundle.IllTransaction.RequesterID = pgTextFromString(peerIDs[bundle.IllTransaction.RequesterSymbol.String]) + for index := range bundle.LocatedSuppliers { + bundle.LocatedSuppliers[index].SupplierID = peerIDs[bundle.LocatedSuppliers[index].SupplierSymbol] + } + } + result, err := i.repo.ImportPatronRequest(ctx, bundle, policy) + return identifier, result, err +} + +func (i Importer) normalizePatronRequest(owner string, apiBundle importoapi.ImportPatronRequestBundle) (importdb.PatronRequestBundle, []string, error) { + request := apiBundle.PatronRequest + if request.Id == "" { + return importdb.PatronRequestBundle{}, nil, errors.New("a required full migration bundle is required: patronRequest.id is required") + } + if request.CreatedAt.IsZero() || request.UpdatedAt.IsZero() { + return importdb.PatronRequestBundle{}, nil, errors.New("patronRequest.createdAt and updatedAt are required") + } + if request.RequesterRequestId == "" || request.RequesterSymbol == "" || request.StateModel == "" || request.State == "" { + return importdb.PatronRequestBundle{}, nil, errors.New("patronRequest requesterRequestId, requesterSymbol, stateModel, and state are required") + } + if apiBundle.Items == nil || apiBundle.Notifications == nil || apiBundle.LocatedSuppliers == nil { + return importdb.PatronRequestBundle{}, nil, errors.New("items, notifications, and locatedSuppliers arrays are required") + } + if request.IllRequest.ServiceInfo == nil || request.IllRequest.ServiceInfo.ServiceType == "" { + return importdb.PatronRequestBundle{}, nil, errors.New("patronRequest.illRequest.serviceInfo.serviceType is required") + } + serviceType := proapi.StateModelServiceType(request.IllRequest.ServiceInfo.ServiceType) + if !serviceType.Valid() { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("unsupported service type %q", serviceType) + } + var side pr_db.PatronRequestSide + switch pr_db.PatronRequestSide(request.Side) { + case prservice.SideBorrowing: + side = prservice.SideBorrowing + case prservice.SideLending: + side = prservice.SideLending + default: + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("unsupported patron request side %q", request.Side) + } + if i.stateValidator == nil { + return importdb.PatronRequestBundle{}, nil, errors.New("state validator is required") + } + terminal, err := i.stateValidator.ValidateImportState(request.StateModel, serviceType, side, pr_db.PatronRequestState(request.State)) + if err != nil { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("validate patron request state: %w", err) + } + + illResponse := request.IllResponse + var responseValue iso18626.SupplyingAgencyMessage + if illResponse != nil { + responseValue = *illResponse + } + bundle := importdb.PatronRequestBundle{PatronRequest: pr_db.CreatePatronRequestParams{ + ID: request.Id, CreatedAt: pgTimestamp(request.CreatedAt), UpdatedAt: pgTimestamp(request.UpdatedAt), + IllRequest: request.IllRequest, IllResponse: responseValue, + State: pr_db.PatronRequestState(request.State), Side: side, + Patron: pgTextFromPtr(request.Patron), RequesterSymbol: pgTextFromString(request.RequesterSymbol), + SupplierSymbol: pgTextFromPtr(request.SupplierSymbol), Tenant: pgTextFromString(owner), + RequesterReqID: pgTextFromString(request.RequesterRequestId), NeedsAttention: request.NeedsAttention, + LastAction: pgTextFromPtr(request.LastAction), LastActionOutcome: pgTextFromPtr(request.LastActionOutcome), + LastActionResult: pgTextFromPtr(request.LastActionResult), Items: []pr_db.PrItem{}, Language: pr_db.LANGUAGE, + TerminalState: terminal, InternalNote: pgTextFromPtr(request.InternalNote), NextReqID: pgTextFromPtr(request.NextReqId), + PrevReqID: pgTextFromPtr(request.PrevReqId), RetryBibInfo: request.RetryBibInfo, StateModel: request.StateModel, + }} + + seenItems := make(map[string]struct{}, len(apiBundle.Items)) + for _, item := range apiBundle.Items { + if item.Id == "" || item.Barcode == "" || item.CreatedAt.IsZero() { + return importdb.PatronRequestBundle{}, nil, errors.New("every item requires id, barcode, and createdAt") + } + if _, duplicate := seenItems[item.Id]; duplicate { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("duplicate item id %q", item.Id) + } + seenItems[item.Id] = struct{}{} + bundle.Items = append(bundle.Items, pr_db.SaveItemParams{ID: item.Id, Barcode: item.Barcode, CallNumber: pgTextFromPtr(item.CallNumber), Title: pgTextFromPtr(item.Title), ItemID: pgTextFromPtr(item.ItemId), LmsRequestID: pgTextFromPtr(item.LmsRequestId), CreatedAt: pgTimestamp(item.CreatedAt)}) + } + + seenNotifications := make(map[string]struct{}, len(apiBundle.Notifications)) + for _, notification := range apiBundle.Notifications { + if notification.Id == "" || notification.FromSymbol == "" || notification.ToSymbol == "" || notification.CreatedAt.IsZero() { + return importdb.PatronRequestBundle{}, nil, errors.New("every notification requires id, fromSymbol, toSymbol, and createdAt") + } + if !notification.Direction.Valid() || !notification.Kind.Valid() { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("notification %q has invalid direction or kind", notification.Id) + } + if _, duplicate := seenNotifications[notification.Id]; duplicate { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("duplicate notification id %q", notification.Id) + } + seenNotifications[notification.Id] = struct{}{} + cost, err := pgNumericFromFloat(notification.Cost) + if err != nil { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("notification %q cost: %w", notification.Id, err) + } + bundle.Notifications = append(bundle.Notifications, pr_db.SaveNotificationParams{ID: notification.Id, FromSymbol: notification.FromSymbol, ToSymbol: notification.ToSymbol, Direction: pr_db.NotificationDirection(notification.Direction), Kind: pr_db.NotificationKind(notification.Kind), Note: pgTextFromPtr(notification.Note), Cost: cost, Currency: pgTextFromPtr(notification.Currency), Condition: pgTextFromPtr(notification.Condition), Receipt: pr_db.NotificationReceipt(valueOrEmpty(notification.Receipt)), CreatedAt: pgTimestamp(notification.CreatedAt), AcknowledgedAt: pgTimestampFromPtr(notification.AcknowledgedAt)}) + } + + if apiBundle.IllTransaction == nil { + if len(apiBundle.LocatedSuppliers) != 0 { + return importdb.PatronRequestBundle{}, nil, errors.New("locatedSuppliers require illTransaction") + } + return bundle, nil, nil + } + ill := apiBundle.IllTransaction + if ill.Id == "" || ill.RequesterRequestID == "" || ill.RequesterSymbol == "" || ill.Timestamp.IsZero() { + return importdb.PatronRequestBundle{}, nil, errors.New("illTransaction id, requesterRequestID, requesterSymbol, and timestamp are required") + } + if ill.RequesterRequestID != request.RequesterRequestId { + return importdb.PatronRequestBundle{}, nil, errors.New("patronRequest and illTransaction requester request IDs must match") + } + bundle.IllTransaction = &ill_db.SaveIllTransactionParams{ID: ill.Id, Timestamp: pgTimestamp(ill.Timestamp), RequesterSymbol: pgTextFromString(ill.RequesterSymbol), LastRequesterAction: pgTextFromPtr(ill.LastRequesterAction), PrevRequesterAction: pgTextFromPtr(ill.PrevRequesterAction), SupplierSymbol: pgTextFromPtr(ill.SupplierSymbol), RequesterRequestID: pgTextFromString(ill.RequesterRequestID), PrevRequesterRequestID: pgTextFromPtr(ill.PrevRequesterRequestID), SupplierRequestID: pgTextFromPtr(ill.SupplierRequestID), LastSupplierStatus: pgTextFromPtr(ill.LastSupplierStatus), PrevSupplierStatus: pgTextFromPtr(ill.PrevSupplierStatus), IllTransactionData: ill.IllTransactionData} + symbols := []string{ill.RequesterSymbol} + seenSuppliers := make(map[string]struct{}, len(apiBundle.LocatedSuppliers)) + for _, supplier := range apiBundle.LocatedSuppliers { + if supplier.Id == "" || supplier.SupplierSymbol == "" { + return importdb.PatronRequestBundle{}, nil, errors.New("every located supplier requires id and supplierSymbol") + } + if supplier.SupplierStatus != nil && !supplier.SupplierStatus.Valid() { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("located supplier %q has invalid status", supplier.Id) + } + if _, duplicate := seenSuppliers[supplier.Id]; duplicate { + return importdb.PatronRequestBundle{}, nil, fmt.Errorf("duplicate located supplier id %q", supplier.Id) + } + seenSuppliers[supplier.Id] = struct{}{} + bundle.LocatedSuppliers = append(bundle.LocatedSuppliers, ill_db.SaveLocatedSupplierParams{ID: supplier.Id, SupplierSymbol: supplier.SupplierSymbol, Ordinal: supplier.Ordinal, SupplierStatus: pgTextFromString(stringValue(supplier.SupplierStatus)), PrevAction: pgTextFromPtr(supplier.PrevAction), PrevStatus: pgTextFromPtr(supplier.PrevStatus), LastAction: pgTextFromPtr(supplier.LastAction), LastStatus: pgTextFromPtr(supplier.LastStatus), LocalID: pgTextFromPtr(supplier.LocalID), PrevReason: pgTextFromPtr(supplier.PrevReason), LastReason: pgTextFromPtr(supplier.LastReason), SupplierRequestID: pgTextFromPtr(supplier.SupplierRequestID), LocalSupplier: supplier.LocalSupplier}) + symbols = appendStableUnique(symbols, supplier.SupplierSymbol) + } + return bundle, symbols, nil +} + +func (i Importer) importBatchAction(ctx common.ExtendedContext, policy importdb.ConflictPolicy, owner string, data json.RawMessage) (*string, importdb.Result, error) { + if i.repo == nil { + return nil, importdb.Result{}, errors.New("import repository is required") + } + err := i.validateOwner(ctx, owner) + if err != nil { + return nil, importdb.Result{}, fmt.Errorf("validate owner: %w", err) + } + var create schedoapi.CreateBatchAction + if err = json.Unmarshal(data, &create); err != nil { + return nil, importdb.Result{}, err + } + if create.Title == nil || *create.Title == "" { + return nil, importdb.Result{}, errors.New("title must not be empty") + } + if !create.ActionName.Valid() { + return create.Title, importdb.Result{}, fmt.Errorf("unknown actionName: %s", create.ActionName) + } + if create.Schedule == "" { + return create.Title, importdb.Result{}, errors.New("schedule must not be empty") + } + if create.BatchQuery == "" { + return create.Title, importdb.Result{}, errors.New("batchQuery must not be empty") + } + nextRun, err := schedservice.NextScheduleTime(create.Schedule) + if err != nil { + return create.Title, importdb.Result{}, err + } + taskID := uuid.NewString() + paramsMap := map[string]any{} + if create.ActionParams != nil { + paramsMap = *create.ActionParams + } + now := pgtype.Timestamptz{Time: i.clock(), Valid: true} + result, err := i.repo.ImportBatchAction(ctx, sched_db.SaveScheduledTaskParams{ID: taskID, EventName: events.EventNameInvokeBatchAction, Schedule: create.Schedule, ActionData: events.EventData{CommonEventData: events.CommonEventData{BatchActionData: &events.BatchActionData{ActionName: string(create.ActionName), Selector: create.BatchQuery, TaskId: taskID, Owner: owner}}, CustomData: paramsMap}, Title: pgTextFromPtr(create.Title), RunAt: nextRun, Status: sched_db.ScheduledTaskStatusPending, Owner: owner, CreatedAt: now, UpdatedAt: now}, policy) + return create.Title, result, err +} + +func (i Importer) importTemplate(ctx common.ExtendedContext, policy importdb.ConflictPolicy, owner string, data json.RawMessage) (*string, importdb.Result, error) { + if i.repo == nil { + return nil, importdb.Result{}, errors.New("import repository is required") + } + err := i.validateOwner(ctx, owner) + if err != nil { + return nil, importdb.Result{}, fmt.Errorf("validate owner: %w", err) + } + var create proapi.CreateTemplate + if err = json.Unmarshal(data, &create); err != nil { + return nil, importdb.Result{}, err + } + if len(create.Labels) == 0 { + return nil, importdb.Result{}, errors.New("labels is required") + } + labels := strings.Join(create.Labels, ",") + if create.Title == "" || create.Body == "" || create.Purpose == "" || create.ContentType == "" || create.Audience == nil { + return &labels, importdb.Result{}, errors.New("title, body, purpose, contentType, and audience are required") + } + if !create.Purpose.Valid() { + return &labels, importdb.Result{}, fmt.Errorf("invalid purpose: %s", create.Purpose) + } + if !create.ContentType.Valid() { + return &labels, importdb.Result{}, fmt.Errorf("invalid contentType: %s", create.ContentType) + } + if !create.Audience.Valid() { + return &labels, importdb.Result{}, fmt.Errorf("invalid audience: %s", *create.Audience) + } + now := pgtype.Timestamp{Time: i.clock(), Valid: true} + result, err := i.repo.ImportTemplate(ctx, pr_db.SaveTemplateParams{ID: uuid.NewString(), Owner: owner, Title: create.Title, Purpose: string(create.Purpose), Subject: pgTextFromPtr(create.Subject), Body: create.Body, ContentType: string(create.ContentType), Labels: create.Labels, Audience: pgTextFromString(string(*create.Audience)), CreatedAt: now, UpdatedAt: now}, policy) + return &labels, result, err +} + +func (i Importer) validateOwner(ctx common.ExtendedContext, owner string) error { + if owner == "" { + return errors.New("owner is required") + } + peers, _, err := i.peerCache.GetCachedPeersBySymbols(ctx, []string{owner}, i.directoryAdapter) + if err != nil { + return err + } + if len(peers) == 0 { + return errors.New("owner not found") + } + return nil +} + +func pgTextFromPtr(value *string) pgtype.Text { + if value == nil { + return pgtype.Text{} + } + return pgTextFromString(*value) +} +func pgTextFromString(value string) pgtype.Text { + return pgtype.Text{String: value, Valid: value != ""} +} +func pgTimestamp(value time.Time) pgtype.Timestamp { + return pgtype.Timestamp{Time: value, Valid: !value.IsZero()} +} +func pgTimestampFromPtr(value *time.Time) pgtype.Timestamp { + if value == nil { + return pgtype.Timestamp{} + } + return pgTimestamp(*value) +} +func stringPtr(value string) *string { + if value == "" { + return nil + } + return &value +} +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} +func stringValue[T ~string](value *T) string { + if value == nil { + return "" + } + return string(*value) +} + +func pgNumericFromFloat(value *float64) (pgtype.Numeric, error) { + if value == nil { + return pgtype.Numeric{}, nil + } + var numeric pgtype.Numeric + err := numeric.Scan(strconv.FormatFloat(*value, 'f', -1, 64)) + return numeric, err +} + +func appendStableUnique(values []string, value string) []string { + if value == "" { + return values + } + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} + +func validImportItemType(itemType string) bool { + switch itemType { + case importItemTypePatronRequest, importItemTypeBatchAction, importItemTypeTemplate: + return true + default: + return false + } +} + +func addImportSuccess(result *importoapi.ImportResult, itemType string) { + switch itemType { + case importItemTypePatronRequest: + result.PatronRequests.Imported++ + case importItemTypeBatchAction: + result.BatchActions.Imported++ + case importItemTypeTemplate: + result.Templates.Imported++ + } +} + +func addImportSkipped(result *importoapi.ImportResult, line int32, itemType, diagnostic string, owner, identifier *string) { + switch itemType { + case importItemTypePatronRequest: + result.PatronRequests.Skipped++ + case importItemTypeBatchAction: + result.BatchActions.Skipped++ + case importItemTypeTemplate: + result.Templates.Skipped++ + } + importError := importoapi.ImportItemError{Line: line, Error: diagnostic, Owner: owner, Identifier: identifier} + if importType := importItemType(itemType); importType.Valid() { + importError.Type = &importType + } + result.Errors = append(result.Errors, importError) +} + +func addImportFailure(result *importoapi.ImportResult, line int32, itemType string, err error, owner, identifier *string) { + switch itemType { + case importItemTypePatronRequest: + result.PatronRequests.Failed++ + case importItemTypeBatchAction: + result.BatchActions.Failed++ + case importItemTypeTemplate: + result.Templates.Failed++ + } + importError := importoapi.ImportItemError{Line: line, Error: err.Error(), Owner: owner, Identifier: identifier} + if importType := importItemType(itemType); importType.Valid() { + importError.Type = &importType + } + result.Errors = append(result.Errors, importError) +} + +func importItemType(itemType string) importoapi.ImportItemType { + switch itemType { + case importItemTypePatronRequest: + return importoapi.ImportItemTypePatronRequest + case importItemTypeBatchAction: + return importoapi.ImportItemTypeBatchAction + case importItemTypeTemplate: + return importoapi.ImportItemTypeTemplate + default: + return "" + } +} diff --git a/broker/import/service/importer_test.go b/broker/import/service/importer_test.go new file mode 100644 index 00000000..ab51b6ff --- /dev/null +++ b/broker/import/service/importer_test.go @@ -0,0 +1,271 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/indexdata/crosslink/broker/adapter" + "github.com/indexdata/crosslink/broker/common" + ill_db "github.com/indexdata/crosslink/broker/ill_db" + importdb "github.com/indexdata/crosslink/broker/import/db" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/broker/patron_request/proapi" + sched_db "github.com/indexdata/crosslink/broker/scheduler/db" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeImportItemRejectsInvalidEnvelopes(t *testing.T) { + for _, tt := range []struct{ raw, want string }{ + {`{"data":{}}`, "type is required"}, + {`{"type":"template"}`, "data is required"}, + {`{"type":"template","data":[]}`, "data must be an object"}, + {`{"type":"unknown","data":{}}`, "unknown type: unknown"}, + } { + _, err := decodeImportItem(json.RawMessage(tt.raw)) + require.EqualError(t, err, tt.want) + } +} + +func TestImportPatronRequestNormalizesCompleteBundle(t *testing.T) { + repo := &recordingImportRepo{patronResult: importdb.Result{Outcome: importdb.OutcomeImported}} + validator := &recordingStateValidator{terminal: true} + cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "peer-requester"}, {ID: "peer-supplier"}}} + importer := newImporter(repo, cache, nil, validator, fixedClock) + + id, result, err := importer.importPatronRequest(testCtx(), importdb.ConflictPolicyUpdate, "ISIL:OWNER", validPatronBundleData()) + + require.NoError(t, err) + assert.Equal(t, "pr-1", *id) + assert.Equal(t, importdb.OutcomeImported, result.Outcome) + assert.Equal(t, importdb.ConflictPolicyUpdate, repo.patronPolicy) + assert.Equal(t, "pr-1", repo.patron.PatronRequest.ID) + assert.Equal(t, pgText("ISIL:OWNER"), repo.patron.PatronRequest.Tenant) + assert.Equal(t, pr_db.PatronRequestSide("borrowing"), repo.patron.PatronRequest.Side) + assert.Equal(t, pr_db.PatronRequestState("SENT"), repo.patron.PatronRequest.State) + assert.True(t, repo.patron.PatronRequest.TerminalState) + assert.Equal(t, fixedTime("2026-08-01T10:00:00Z"), repo.patron.PatronRequest.CreatedAt.Time) + require.Len(t, repo.patron.Items, 1) + assert.Equal(t, "lms-1", repo.patron.Items[0].LmsRequestID.String) + require.Len(t, repo.patron.Notifications, 1) + assert.True(t, repo.patron.Notifications[0].AcknowledgedAt.Valid) + require.NotNil(t, repo.patron.IllTransaction) + assert.Equal(t, pgText("peer-requester"), repo.patron.IllTransaction.RequesterID) + require.Len(t, repo.patron.LocatedSuppliers, 1) + assert.Equal(t, "peer-supplier", repo.patron.LocatedSuppliers[0].SupplierID) + assert.Equal(t, []string{"ISIL:REQ", "ISIL:SUP"}, cache.symbols) + assert.Equal(t, "default", validator.model) + assert.Equal(t, proapi.Loan, validator.serviceType) +} + +func TestImportPatronRequestValidatesBeforeCachingPeers(t *testing.T) { + repo := &recordingImportRepo{} + cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "only-one"}}} + validator := &recordingStateValidator{err: errors.New("unsupported state")} + importer := newImporter(repo, cache, nil, validator, fixedClock) + _, _, err := importer.importPatronRequest(testCtx(), importdb.ConflictPolicyFail, "ISIL:OWNER", validPatronBundleData()) + require.ErrorContains(t, err, "unsupported state") + assert.Equal(t, 1, cache.calls) + assert.Zero(t, repo.patronCalls) +} + +func TestImportPatronRequestRejectsIncompletePeerResolution(t *testing.T) { + repo := &recordingImportRepo{} + cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "only-one"}}} + importer := newImporter(repo, cache, nil, &recordingStateValidator{}, fixedClock) + _, _, err := importer.importPatronRequest(testCtx(), importdb.ConflictPolicyFail, "ISIL:OWNER", validPatronBundleData()) + require.ErrorContains(t, err, "expected 2 peers, got 1") + assert.Zero(t, repo.patronCalls) +} + +func TestImporterAccountsForImportedSkippedAndFailed(t *testing.T) { + repo := &recordingImportRepo{templateResults: []importdb.Result{{Outcome: importdb.OutcomeImported}, {Outcome: importdb.OutcomeSkipped, Diagnostic: "labels already exist"}}, templateErrors: []error{nil, nil, errors.New("write failed")}} + cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "only-one"}}} + importer := newImporter(repo, cache, nil, nil, fixedClock) + body := "" + for range 3 { + body += `{"type":"template","owner":"ISIL:OWNER","data":` + string(validTemplateData()) + `}` + "\n" + } + result, err := importer.Import(testCtx(), importdb.ConflictPolicySkip, strings.NewReader(body)) + require.NoError(t, err) + assert.Equal(t, int32(1), result.Templates.Imported) + assert.Equal(t, int32(1), result.Templates.Skipped) + assert.Equal(t, int32(1), result.Templates.Failed) + require.Len(t, result.Errors, 2) + assert.Equal(t, int32(2), result.Errors[0].Line) + assert.Equal(t, "labels already exist", result.Errors[0].Error) + assert.Equal(t, int32(3), result.Errors[1].Line) + assert.Equal(t, importdb.ConflictPolicySkip, repo.templatePolicy) +} + +func TestImportTemplateRejectsInvalidEnums(t *testing.T) { + tests := []struct { + name string + data string + wantErr string + }{ + { + name: "purpose", + data: `{"title":"Title","purpose":"sms","body":"Body","contentType":"text","labels":["first"],"audience":"patron"}`, + wantErr: "invalid purpose", + }, + { + name: "content type", + data: `{"title":"Title","purpose":"email","body":"Body","contentType":"text/plain","labels":["first"],"audience":"patron"}`, + wantErr: "invalid contentType", + }, + { + name: "audience", + data: `{"title":"Title","purpose":"email","body":"Body","contentType":"text","labels":["first"],"audience":"external"}`, + wantErr: "invalid audience", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := &recordingImportRepo{} + cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "owner-peer"}}} + importer := newImporter(repo, cache, nil, nil, fixedClock) + + _, _, err := importer.importTemplate(testCtx(), importdb.ConflictPolicyFail, "ISIL:OWNER", json.RawMessage(tt.data)) + + require.ErrorContains(t, err, tt.wantErr) + assert.Zero(t, repo.templateCalls) + }) + } +} + +func TestImporterAcceptsRecordAtSizeLimit(t *testing.T) { + repo := &recordingImportRepo{} + importer := newImporter(repo, &recordingPeerCache{peers: []ill_db.Peer{{ID: "owner-peer"}}}, nil, nil, fixedClock) + importer.maxRecordBytes = 256 + prefix := `{"type":"template","owner":"ISIL:OWNER","data":{"title":"Title","purpose":"email","body":"` + suffix := `","contentType":"text","labels":["first"],"audience":"patron"}}` + record := prefix + strings.Repeat("x", 256-len(prefix)-len(suffix)) + suffix + + result, err := importer.Import(testCtx(), importdb.ConflictPolicyFail, strings.NewReader(record+"\n")) + + require.NoError(t, err) + assert.Equal(t, int32(1), result.Templates.Imported) +} + +func TestImporterRejectsRecordOverSizeLimit(t *testing.T) { + repo := &recordingImportRepo{} + importer := newImporter(repo, &recordingPeerCache{}, nil, nil, fixedClock) + importer.maxRecordBytes = 128 + + _, err := importer.Import(testCtx(), importdb.ConflictPolicyFail, strings.NewReader(strings.Repeat("x", 129)+"\n")) + + assert.ErrorIs(t, err, ErrImportRecordTooLarge) + assert.Zero(t, repo.patronCalls) + assert.Zero(t, repo.templateCalls) +} + +func TestImporterForwardsPolicyToBatchAction(t *testing.T) { + repo := &recordingImportRepo{batchResult: importdb.Result{Outcome: importdb.OutcomeImported}} + cache := &recordingPeerCache{peers: []ill_db.Peer{{ID: "only-one"}}} + importer := newImporter(repo, cache, nil, nil, fixedClock) + _, _, err := importer.importBatchAction(testCtx(), importdb.ConflictPolicyUpdate, "ISIL:OWNER", validBatchActionData()) + require.NoError(t, err) + assert.Equal(t, importdb.ConflictPolicyUpdate, repo.batchPolicy) + assert.Equal(t, "ISIL:OWNER", repo.batch.Owner) +} + +type recordingImportRepo struct { + patron importdb.PatronRequestBundle + patronPolicy importdb.ConflictPolicy + patronResult importdb.Result + patronErr error + patronCalls int + template pr_db.SaveTemplateParams + templatePolicy importdb.ConflictPolicy + templateResults []importdb.Result + templateErrors []error + templateCalls int + batch sched_db.SaveScheduledTaskParams + batchPolicy importdb.ConflictPolicy + batchResult importdb.Result + batchErr error +} + +func (r *recordingImportRepo) WithTxFunc(_ common.ExtendedContext, fn func(importdb.ImportRepo) error) error { + return fn(r) +} + +func (r *recordingImportRepo) ImportPatronRequest(_ common.ExtendedContext, bundle importdb.PatronRequestBundle, policy importdb.ConflictPolicy) (importdb.Result, error) { + r.patron, r.patronPolicy, r.patronCalls = bundle, policy, r.patronCalls+1 + return r.patronResult, r.patronErr +} +func (r *recordingImportRepo) ImportTemplate(_ common.ExtendedContext, params pr_db.SaveTemplateParams, policy importdb.ConflictPolicy) (importdb.Result, error) { + r.template, r.templatePolicy, r.templateCalls = params, policy, r.templateCalls+1 + index := r.templateCalls - 1 + var result importdb.Result + if index < len(r.templateResults) { + result = r.templateResults[index] + } else { + result = importdb.Result{Outcome: importdb.OutcomeImported} + } + if index < len(r.templateErrors) { + return result, r.templateErrors[index] + } + return result, nil +} +func (r *recordingImportRepo) ImportBatchAction(_ common.ExtendedContext, params sched_db.SaveScheduledTaskParams, policy importdb.ConflictPolicy) (importdb.Result, error) { + r.batch, r.batchPolicy = params, policy + return r.batchResult, r.batchErr +} + +type recordingStateValidator struct { + model string + serviceType proapi.StateModelServiceType + side pr_db.PatronRequestSide + state pr_db.PatronRequestState + terminal bool + err error +} + +func (v *recordingStateValidator) ValidateImportState(model string, serviceType proapi.StateModelServiceType, side pr_db.PatronRequestSide, state pr_db.PatronRequestState) (bool, error) { + v.model, v.serviceType, v.side, v.state = model, serviceType, side, state + return v.terminal, v.err +} + +type recordingPeerCache struct { + symbols []string + peers []ill_db.Peer + err error + calls int +} + +func (c *recordingPeerCache) GetCachedPeersBySymbols(_ common.ExtendedContext, symbols []string, _ adapter.DirectoryLookupAdapter) ([]ill_db.Peer, string, error) { + c.calls++ + c.symbols = append([]string(nil), symbols...) + return c.peers, "test", c.err +} + +func testCtx() common.ExtendedContext { + return common.CreateExtCtxWithArgs(context.Background(), &common.LoggerArgs{}) +} +func fixedClock() time.Time { return time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC) } +func fixedTime(value string) time.Time { parsed, _ := time.Parse(time.RFC3339, value); return parsed } +func pgText(value string) pgtype.Text { return pgtype.Text{String: value, Valid: true} } + +func validPatronBundleData() json.RawMessage { + return json.RawMessage(`{ + "patronRequest":{"id":"pr-1","createdAt":"2026-08-01T10:00:00Z","updatedAt":"2026-08-02T10:00:00Z","illRequest":{"header":{"requestingAgencyRequestId":"pr-1"},"serviceInfo":{"serviceType":"Loan"}},"state":"SENT","side":"borrowing","requesterSymbol":"ISIL:REQ","requesterRequestId":"request-1","needsAttention":false,"stateModel":"default"}, + "items":[{"id":"item-1","barcode":"barcode-1","lmsRequestId":"lms-1","createdAt":"2026-08-01T10:01:00Z"}], + "notifications":[{"id":"note-1","fromSymbol":"ISIL:REQ","toSymbol":"ISIL:SUP","direction":"sent","kind":"note","cost":1.25,"createdAt":"2026-08-01T10:02:00Z","acknowledgedAt":"2026-08-01T10:03:00Z"}], + "illTransaction":{"id":"ill-1","timestamp":"2026-08-01T10:00:00Z","requesterSymbol":"ISIL:REQ","requesterRequestID":"request-1","supplierSymbol":"ISIL:SUP","illTransactionData":{"bibliographicInfo":{}}}, + "locatedSuppliers":[{"id":"located-1","supplierSymbol":"ISIL:SUP","ordinal":1,"supplierStatus":"selected","localSupplier":false}] + }`) +} +func validTemplateData() json.RawMessage { + return json.RawMessage(`{"title":"Title","purpose":"email","body":"Body","contentType":"text","labels":["first"],"audience":"patron"}`) +} +func validBatchActionData() json.RawMessage { + return json.RawMessage(`{"actionName":"request-aging","batchQuery":"state==NEW","schedule":"FREQ=DAILY;BYHOUR=6;BYMINUTE=0","title":"Daily aging"}`) +} diff --git a/broker/migrations/061_add_uniq_template_and_task.down.sql b/broker/migrations/061_add_uniq_template_and_task.down.sql new file mode 100644 index 00000000..704bbb3e --- /dev/null +++ b/broker/migrations/061_add_uniq_template_and_task.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS trg_check_template_owner_labels_unique ON template; +DROP FUNCTION IF EXISTS check_template_owner_labels_unique(); +DROP INDEX IF EXISTS idx_scheduled_task_owner_title; diff --git a/broker/migrations/061_add_uniq_template_and_task.up.sql b/broker/migrations/061_add_uniq_template_and_task.up.sql new file mode 100644 index 00000000..a504b972 --- /dev/null +++ b/broker/migrations/061_add_uniq_template_and_task.up.sql @@ -0,0 +1,42 @@ +CREATE OR REPLACE FUNCTION check_template_owner_labels_unique() + RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM template t + WHERE t.owner = NEW.owner + AND t.labels && NEW.labels + AND (TG_OP = 'INSERT' AND t.id <> NEW.id) + ) THEN + RAISE EXCEPTION + 'One or more labels already exist for owner %', + NEW.owner; +END IF; +RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER trg_check_template_owner_labels_unique + BEFORE INSERT OR UPDATE OF owner, labels + ON template + FOR EACH ROW + EXECUTE FUNCTION check_template_owner_labels_unique(); + +-- Remove duplicates if already exist +WITH duplicates AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY owner, title + ORDER BY id + ) AS rn + FROM scheduled_task +) +UPDATE scheduled_task st +SET title = st.title || '_' || d.rn + FROM duplicates d +WHERE st.id = d.id + AND d.rn > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_scheduled_task_owner_title + ON scheduled_task (owner, title); \ No newline at end of file diff --git a/broker/oapi/cfg.yaml b/broker/oapi/cfg.yaml index bcc2e5cb..ff44e246 100644 --- a/broker/oapi/cfg.yaml +++ b/broker/oapi/cfg.yaml @@ -6,6 +6,7 @@ output-options: - sse-api - pull-slips-api - scheduler-api + - import-api overlay: path: oapi/overlay.yaml generate: diff --git a/broker/oapi/import-cfg.yaml b/broker/oapi/import-cfg.yaml new file mode 100644 index 00000000..06ed1201 --- /dev/null +++ b/broker/oapi/import-cfg.yaml @@ -0,0 +1,11 @@ +package: importoapi +output: import/oapi/import_openapi_gen.go +output-options: + include-tags: + - import-api + skip-prune: true + overlay: + path: oapi/overlay.yaml +generate: + models: true + std-http-server: true diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index aaf5c960..6dd3856a 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -111,6 +111,268 @@ components: - requester_symbol - supplier_symbol schemas: + ConflictPolicy: + type: string + enum: [fail, skip, update] + default: fail + ImportItemType: + type: string + enum: + - patronRequest + - batchAction + - template + ImportSectionResult: + type: object + additionalProperties: false + properties: + imported: + type: integer + format: int32 + failed: + type: integer + format: int32 + skipped: + type: integer + format: int32 + required: + - imported + - failed + - skipped + ImportItemError: + type: object + additionalProperties: false + properties: + line: + type: integer + format: int32 + description: One-based NDJSON record number. + type: + $ref: '#/components/schemas/ImportItemType' + error: + type: string + owner: + type: string + identifier: + type: string + required: + - line + - error + ImportResult: + type: object + additionalProperties: false + properties: + patronRequests: { $ref: '#/components/schemas/ImportSectionResult' } + batchActions: { $ref: '#/components/schemas/ImportSectionResult' } + templates: { $ref: '#/components/schemas/ImportSectionResult' } + errors: + type: array + items: { $ref: '#/components/schemas/ImportItemError' } + required: [patronRequests, batchActions, templates, errors] + ImportResourceRecord: + type: object + additionalProperties: false + properties: + type: + $ref: '#/components/schemas/ImportItemType' + owner: + type: string + description: Symbol of the owning institution + data: + description: Resource data to import. + oneOf: + - $ref: '#/components/schemas/ImportPatronRequestBundle' + - $ref: '#/components/schemas/CreateBatchAction' + - $ref: '#/components/schemas/CreateTemplate' + required: [type, owner, data] + ImportPatronRequestBundle: + type: object + additionalProperties: false + properties: + patronRequest: + $ref: '#/components/schemas/ImportPatronRequest' + items: + type: array + items: + $ref: '#/components/schemas/ImportPatronRequestItem' + notifications: + type: array + items: + $ref: '#/components/schemas/ImportPatronRequestNotification' + illTransaction: + $ref: '#/components/schemas/ImportIllTransaction' + locatedSuppliers: + type: array + items: + $ref: '#/components/schemas/ImportLocatedSupplier' + required: [patronRequest, items, notifications, locatedSuppliers] + ImportPatronRequest: + type: object + additionalProperties: false + properties: + id: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + illRequest: + type: object + description: JSON of ISO18626 request + state: + type: string + stateModel: + type: string + side: + type: string + patron: + type: string + requesterSymbol: + type: string + supplierSymbol: + type: string + requesterRequestId: + type: string + needsAttention: + type: boolean + lastAction: + type: string + lastActionOutcome: + type: string + lastActionResult: + type: string + illResponse: + type: object + description: JSON of ISO18626 supplying agency message + internalNote: + type: string + nextReqId: + type: string + prevReqId: + type: string + retryBibInfo: + type: object + description: Bibliographic retry information as defined in ISO18626 + required: [id, createdAt, updatedAt, illRequest, state, stateModel, side, requesterSymbol, requesterRequestId, needsAttention] + ImportPatronRequestItem: + type: object + additionalProperties: false + properties: + id: + type: string + barcode: + type: string + callNumber: + type: string + title: + type: string + itemId: + type: string + lmsRequestId: + type: string + createdAt: + type: string + format: date-time + required: [id, barcode, createdAt] + ImportPatronRequestNotification: + type: object + additionalProperties: false + properties: + id: + type: string + fromSymbol: + type: string + toSymbol: + type: string + direction: + type: string + enum: [sent, received] + kind: + type: string + enum: [note, condition] + note: + type: string + cost: + type: number + format: double + currency: + type: string + condition: + type: string + receipt: + type: string + createdAt: + type: string + format: date-time + acknowledgedAt: + type: string + format: date-time + required: [id, fromSymbol, toSymbol, direction, kind, createdAt] + ImportIllTransaction: + type: object + additionalProperties: false + properties: + id: + type: string + timestamp: + type: string + format: date-time + requesterSymbol: + type: string + lastRequesterAction: + type: string + prevRequesterAction: + type: string + supplierSymbol: + type: string + requesterRequestID: + type: string + prevRequesterRequestID: + type: string + supplierRequestID: + type: string + lastSupplierStatus: + type: string + prevSupplierStatus: + type: string + illTransactionData: + type: object + additionalProperties: true + required: [id, timestamp, requesterSymbol, requesterRequestID, illTransactionData] + ImportLocatedSupplier: + type: object + additionalProperties: false + properties: + id: + type: string + supplierSymbol: + type: string + ordinal: + type: integer + format: int32 + supplierStatus: + type: string + enum: [new, selected, skipped] + prevAction: + type: string + prevStatus: + type: string + lastAction: + type: string + lastStatus: + type: string + localID: + type: string + prevReason: + type: string + lastReason: + type: string + supplierRequestID: + type: string + localSupplier: + type: boolean + required: [id, supplierSymbol, ordinal, localSupplier] Index: type: object properties: @@ -1504,6 +1766,46 @@ components: - labels paths: + /import: + post: + summary: Import resources from NDJSON + description: Streams each NDJSON record into existing repository interfaces selected by type. + tags: [import-api] + parameters: + - name: conflictPolicy + in: query + description: How to handle an existing resource identity. + required: false + schema: + $ref: '#/components/schemas/ConflictPolicy' + requestBody: + required: true + content: + application/x-ndjson: + schema: + type: string + format: binary + description: A stream of NDJSON records, each representing a resource to import. Each record must conform to the ImportResourceRecord schema. + examples: + resources: + summary: Three import records + value: | + {"type":"patronRequest","owner":"ISIL:SYM","data":{"patronRequest":{"id":"pr-1","createdAt":"2026-08-26T08:00:00Z","updatedAt":"2026-08-26T09:00:00Z","illRequest":{"header":{"requestingAgencyRequestId":"request-1"},"serviceInfo":{"serviceType":"Loan"}},"state":"SENT","stateModel":"default","side":"borrowing","requesterSymbol":"ISIL:REQ","requesterRequestId":"request-1","needsAttention":false},"items":[],"notifications":[],"locatedSuppliers":[]}} + {"type":"batchAction","owner":"ISIL:SYM","data":{"schedule":"FREQ=DAILY;BYHOUR=6;BYMINUTE=0","actionName":"request-aging","title":"Daily aging","batchQuery":"state==NEW","actionParams":{"interval":"24h"}}} + {"type":"template","owner":"ISIL:SYM","data":{"title":"Request reminder","purpose":"email","subject":"Reminder","body":"Your request is ready","contentType":"text","labels":["request-reminder"],"audience":"patron"}} + x-ndjson-item-schema: + $ref: '#/components/schemas/ImportResourceRecord' + responses: + '200': + description: The stream was processed; individual records may have failed. + content: + application/json: + schema: { $ref: '#/components/schemas/ImportResult' } + '400': + description: The request body is missing or the content type is not application/x-ndjson. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /: get: summary: Retrieve index information diff --git a/broker/oapi/overlay.yaml b/broker/oapi/overlay.yaml index 4aa0c0ea..103ff806 100644 --- a/broker/oapi/overlay.yaml +++ b/broker/oapi/overlay.yaml @@ -3,6 +3,26 @@ info: title: Object field codegen overrides version: 0.0.0 actions: + - target: $.components.schemas.ImportPatronRequest.properties.illRequest + update: + x-go-type: iso18626.Request + x-go-type-import: + path: github.com/indexdata/crosslink/iso18626 + - target: $.components.schemas.ImportPatronRequest.properties.illResponse + update: + x-go-type: iso18626.SupplyingAgencyMessage + x-go-type-import: + path: github.com/indexdata/crosslink/iso18626 + - target: $.components.schemas.ImportPatronRequest.properties.retryBibInfo + update: + x-go-type: iso18626.BibliographicInfo + x-go-type-import: + path: github.com/indexdata/crosslink/iso18626 + - target: $.components.schemas.ImportIllTransaction.properties.illTransactionData + update: + x-go-type: ill_db.IllTransactionData + x-go-type-import: + path: github.com/indexdata/crosslink/broker/ill_db - target: $.components.schemas.Event.properties.eventData update: x-go-type: events.EventData diff --git a/broker/patron_request/service/statemodel.go b/broker/patron_request/service/statemodel.go index eca3df52..dab5f9e7 100644 --- a/broker/patron_request/service/statemodel.go +++ b/broker/patron_request/service/statemodel.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" "github.com/indexdata/crosslink/broker/patron_request/proapi" ) @@ -79,6 +80,46 @@ func (s *StateModelService) GetStateModel(modelName string) (*proapi.StateModel, return stateModel, nil } +// ValidateImportState verifies that an imported patron-request state belongs to +// the selected state model, request side, and service type. It returns the +// model's terminal flag so callers do not need to trust imported derived data. +func (s *StateModelService) ValidateImportState( + modelName string, + serviceType proapi.StateModelServiceType, + side pr_db.PatronRequestSide, + state pr_db.PatronRequestState, +) (bool, error) { + stateModel, err := s.GetStateModel(modelName) + if err != nil { + return false, fmt.Errorf("load state model %q: %w", modelName, err) + } + if stateModel == nil { + return false, fmt.Errorf("state model %q not found", modelName) + } + + var modelSide proapi.ModelStateSide + switch side { + case SideBorrowing: + modelSide = proapi.REQUESTER + case SideLending: + modelSide = proapi.SUPPLIER + default: + return false, fmt.Errorf("unsupported patron request side %q", side) + } + + for _, modelState := range stateModel.States { + if modelState.Name == string(state) && modelState.Side == modelSide && + appliesToServiceType(modelState.AppliesTo, serviceType) { + return modelState.Terminal != nil && *modelState.Terminal, nil + } + } + + return false, fmt.Errorf( + "state %q is not supported by state model %q for side %q and service type %q", + state, modelName, side, serviceType, + ) +} + func (s *StateModelService) GetActionMapping(modelName string, serviceType proapi.StateModelServiceType) (*ActionMapping, error) { modelName = canonicalStateModelName(modelName) key := actionMappingKey{modelName: modelName, serviceType: serviceType} diff --git a/broker/patron_request/service/statemodel_test.go b/broker/patron_request/service/statemodel_test.go index a3b6f66f..158fe89c 100644 --- a/broker/patron_request/service/statemodel_test.go +++ b/broker/patron_request/service/statemodel_test.go @@ -120,6 +120,87 @@ func TestLegacyReturnablesStateModelAlias(t *testing.T) { assert.Same(t, defaultModel, legacyModel) } +func TestValidateImportState(t *testing.T) { + service := &StateModelService{} + + tests := []struct { + name string + modelName string + serviceType proapi.StateModelServiceType + side pr_db.PatronRequestSide + state pr_db.PatronRequestState + terminal bool + errorText string + }{ + { + name: "accepts a non-initial requester state", + modelName: "default", + serviceType: proapi.Loan, + side: SideBorrowing, + state: BorrowerStateSent, + }, + { + name: "returns the configured terminal flag", + modelName: "default", + serviceType: proapi.Loan, + side: SideLending, + state: LenderStateCompleted, + terminal: true, + }, + { + name: "supports the legacy model alias", + modelName: "returnables", + serviceType: proapi.Loan, + side: SideBorrowing, + state: BorrowerStateSent, + }, + { + name: "rejects an unknown model", + modelName: "missing", + serviceType: proapi.Loan, + side: SideBorrowing, + state: BorrowerStateSent, + errorText: `state model "missing" not found`, + }, + { + name: "rejects an unknown state", + modelName: "default", + serviceType: proapi.Loan, + side: SideBorrowing, + state: pr_db.PatronRequestState("NOT_A_STATE"), + errorText: `state "NOT_A_STATE" is not supported`, + }, + { + name: "rejects a state from the other side", + modelName: "default", + serviceType: proapi.Loan, + side: SideBorrowing, + state: LenderStateItemPending, + errorText: `state "ITEM_PENDING" is not supported`, + }, + { + name: "rejects a state inapplicable to the service type", + modelName: "default", + serviceType: proapi.Copy, + side: SideLending, + state: LenderStateShipped, + errorText: `state "SHIPPED" is not supported`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + terminal, err := service.ValidateImportState(tt.modelName, tt.serviceType, tt.side, tt.state) + assert.Equal(t, tt.terminal, terminal) + if tt.errorText == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.errorText) + } + }) + } +} + func TestValidateStateModelRejectsEmptyAppliesToServiceTypes(t *testing.T) { model, err := LoadStateModelByName("default") if !assert.NoError(t, err) { diff --git a/broker/sqlc/import_query.sql b/broker/sqlc/import_query.sql new file mode 100644 index 00000000..0581b2cd --- /dev/null +++ b/broker/sqlc/import_query.sql @@ -0,0 +1,82 @@ +-- name: LockImportPatronRequest :one +SELECT id, requester_req_id +FROM patron_request +WHERE id = $1 +FOR UPDATE; + +-- name: UpdateImportedPatronRequest :exec +UPDATE patron_request +SET created_at = sqlc.arg(created_at), + ill_request = sqlc.arg(ill_request), + state = sqlc.arg(state), + side = sqlc.arg(side), + patron = sqlc.arg(patron), + requester_symbol = sqlc.arg(requester_symbol), + supplier_symbol = sqlc.arg(supplier_symbol), + tenant = sqlc.arg(tenant), + requester_req_id = sqlc.arg(requester_req_id), + needs_attention = sqlc.arg(needs_attention), + last_action = sqlc.arg(last_action), + last_action_outcome = sqlc.arg(last_action_outcome), + last_action_result = sqlc.arg(last_action_result), + items = sqlc.arg(items), + language = sqlc.arg(language), + terminal_state = sqlc.arg(terminal_state), + updated_at = sqlc.arg(updated_at), + ill_response = sqlc.arg(ill_response), + internal_note = sqlc.arg(internal_note), + next_req_id = sqlc.arg(next_req_id), + prev_req_id = sqlc.arg(prev_req_id), + retry_bib_info = sqlc.arg(retry_bib_info), + state_model = sqlc.arg(state_model) +WHERE id = sqlc.arg(id); + +-- name: DeleteImportedItemsNotPresent :exec +DELETE FROM item +WHERE pr_id = sqlc.arg(pr_id) + AND id <> ALL(sqlc.arg(ids)::text[]); + +-- name: DeleteImportedNotificationsNotPresent :exec +DELETE FROM notification +WHERE pr_id = sqlc.arg(pr_id) + AND id <> ALL(sqlc.arg(ids)::text[]); + +-- name: DeleteImportedLocatedSuppliersNotPresent :exec +DELETE FROM located_supplier +WHERE ill_transaction_id = sqlc.arg(ill_transaction_id) + AND id <> ALL(sqlc.arg(ids)::text[]); + +-- name: GetImportItemParent :one +SELECT pr_id FROM item WHERE id = $1; + +-- name: GetImportNotificationParent :one +SELECT pr_id FROM notification WHERE id = $1; + +-- name: GetImportLocatedSupplierParent :one +SELECT ill_transaction_id FROM located_supplier WHERE id = $1; + +-- name: LockImportIllTransaction :one +SELECT id, requester_request_id +FROM ill_transaction +WHERE id = $1 +FOR UPDATE; + +-- name: GetImportIllTransactionByRequesterRequestID :one +SELECT id, requester_request_id +FROM ill_transaction +WHERE requester_request_id = $1; + +-- name: LockImportTemplatesByLabels :many +SELECT id, created_at +FROM template +WHERE owner = sqlc.arg(owner) + AND labels && sqlc.arg(labels)::text[] +ORDER BY id +FOR UPDATE; + +-- name: LockImportBatchAction :one +SELECT id, created_at +FROM scheduled_task +WHERE owner = sqlc.arg(owner) + AND title = sqlc.arg(title) +FOR UPDATE; diff --git a/broker/sqlc/sqlc.yaml b/broker/sqlc/sqlc.yaml index 8c2cc75f..502c7cbc 100644 --- a/broker/sqlc/sqlc.yaml +++ b/broker/sqlc/sqlc.yaml @@ -176,3 +176,48 @@ sql: go_type: import: "github.com/indexdata/crosslink/broker/events" type: "EventData" + - engine: "postgresql" + queries: "import_query.sql" + schema: + - "ill_schema.sql" + - "pr_schema.sql" + - "sched_schema.sql" + gen: + go: + package: "importdb" + out: "../import/db" + output_db_file_name: "import_db_gen.go" + output_models_file_name: "import_models_gen.go" + output_files_suffix: "_gen" + sql_package: "pgx/v5" + emit_methods_with_db_argument: true + overrides: + - column: "patron_request.ill_request" + go_type: + import: "github.com/indexdata/crosslink/iso18626" + type: "Request" + - column: "patron_request.ill_response" + go_type: + import: "github.com/indexdata/crosslink/iso18626" + type: "SupplyingAgencyMessage" + - column: "patron_request.state" + go_type: + import: "github.com/indexdata/crosslink/broker/patron_request/db" + package: "pr_db" + type: "PatronRequestState" + - column: "patron_request.side" + go_type: + import: "github.com/indexdata/crosslink/broker/patron_request/db" + package: "pr_db" + type: "PatronRequestSide" + - column: "patron_request.items" + go_type: + import: "github.com/indexdata/crosslink/broker/patron_request/db" + package: "pr_db" + type: "PrItem" + slice: true + - column: "patron_request.retry_bib_info" + go_type: + import: "github.com/indexdata/crosslink/iso18626" + type: "BibliographicInfo" + pointer: true