From 4dd2522177e8eb55a32798a2d9dc7922e008a701 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Tue, 18 Aug 2026 13:47:21 +0300 Subject: [PATCH 1/9] ILLDEV-466 Use template record for PDF pullslip --- broker/email/email.go | 206 +++++++++++ broker/email/email_test.go | 322 ++++++++++++++++++ broker/oapi/open-api.yaml | 3 + broker/patron_request/api/api-handler_test.go | 3 +- broker/patron_request/service/action.go | 25 +- .../patron_request/service/action_mapping.go | 8 + broker/patron_request/service/action_test.go | 4 +- .../patron_request/service/statemodel_test.go | 32 ++ broker/pullslip/api/api_handler_test.go | 13 + broker/pullslip/service/pdf.go | 206 ++--------- broker/pullslip/service/pdf_test.go | 266 ++++----------- .../pullslip/service/pull_slip_template.html | 120 ------- broker/scheduler/service/email_sender.go | 18 +- broker/test/pullslip/api/api_handler_test.go | 25 ++ misc/state-models.yaml | 129 +++++++ 15 files changed, 862 insertions(+), 518 deletions(-) delete mode 100644 broker/pullslip/service/pull_slip_template.html diff --git a/broker/email/email.go b/broker/email/email.go index 92aa2f8fe..cc8ca0c4b 100644 --- a/broker/email/email.go +++ b/broker/email/email.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "html/template" "mime" "mime/multipart" "mime/quotedprintable" @@ -12,9 +13,14 @@ import ( "net/textproto" "strings" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/iso18626" "github.com/indexdata/go-utils/utils" ) +const DEFAULT_FOR_NO_VALUE = "n/a" +const DATE_LAYOUT = "2006-01-02" + // Environment variables for SMTP configuration. var ( SMTP_HOST = utils.GetEnv("SMTP_HOST", "") @@ -180,3 +186,203 @@ func joinAddresses(addrs []string) string { } return result } + +type PullSlipData struct { + BorrowerName string + ReqId string + PickupLocation string + Title string + Author string + DueDate string + ReturnAddress string + BarcodeBase64 string + ServiceType string + ServiceLevel string + SystemIdentifier string + Publisher string + Volume string + Issue string + Pages string + StaffNotes string + CallNumber string + LoanConditions string + PatronName string + PatronSurname string + PatronId string +} + +func GetPullSlipData(pr pr_db.PatronRequest, notes []pr_db.Notification, conditions []pr_db.Notification, barcodeData string) PullSlipData { + data := PullSlipData{ + ReqId: pr.RequesterReqID.String, + PickupLocation: getPickupLocation(pr), + Title: DEFAULT_FOR_NO_VALUE, + Author: DEFAULT_FOR_NO_VALUE, + DueDate: DEFAULT_FOR_NO_VALUE, + ReturnAddress: DEFAULT_FOR_NO_VALUE, + BarcodeBase64: barcodeData, + ServiceType: DEFAULT_FOR_NO_VALUE, + ServiceLevel: DEFAULT_FOR_NO_VALUE, + SystemIdentifier: DEFAULT_FOR_NO_VALUE, + Publisher: DEFAULT_FOR_NO_VALUE, + Volume: DEFAULT_FOR_NO_VALUE, + Issue: DEFAULT_FOR_NO_VALUE, + Pages: DEFAULT_FOR_NO_VALUE, + StaffNotes: getStaffNotes(notes), + CallNumber: getCallNumber(pr), + LoanConditions: getLoanConditions(conditions), + PatronName: DEFAULT_FOR_NO_VALUE, + PatronSurname: DEFAULT_FOR_NO_VALUE, + PatronId: DEFAULT_FOR_NO_VALUE, + } + if pr.IllRequest.BibliographicInfo.Author != "" { + data.Author = pr.IllRequest.BibliographicInfo.Author + } + if pr.IllRequest.BibliographicInfo.Title != "" { + data.Title = pr.IllRequest.BibliographicInfo.Title + } + if pr.IllRequest.BibliographicInfo.Volume != "" { + data.Volume = pr.IllRequest.BibliographicInfo.Volume + } + if pr.IllRequest.BibliographicInfo.Issue != "" { + data.Issue = pr.IllRequest.BibliographicInfo.Issue + } + if pr.IllRequest.BibliographicInfo.EstimatedNoPages != "" { + data.Pages = pr.IllRequest.BibliographicInfo.EstimatedNoPages + } + if pr.IllRequest.BibliographicInfo.SupplierUniqueRecordId != "" { + data.SystemIdentifier = pr.IllRequest.BibliographicInfo.SupplierUniqueRecordId + } + if pr.IllRequest.PublicationInfo != nil && pr.IllRequest.PublicationInfo.Publisher != "" { + data.Publisher = pr.IllRequest.PublicationInfo.Publisher + } + if pr.IllResponse.StatusInfo.DueDate != nil { + data.DueDate = pr.IllResponse.StatusInfo.DueDate.Format(DATE_LAYOUT) + } + if pr.IllResponse.ReturnInfo != nil && pr.IllResponse.ReturnInfo.PhysicalAddress != nil { + data.ReturnAddress = formatPhysicalAddress(pr.IllResponse.ReturnInfo.PhysicalAddress) + } + if pr.IllRequest.ServiceInfo != nil { + if pr.IllRequest.ServiceInfo.ServiceLevel != nil && pr.IllRequest.ServiceInfo.ServiceLevel.Text != "" { + data.ServiceLevel = pr.IllRequest.ServiceInfo.ServiceLevel.Text + } + if pr.IllRequest.ServiceInfo.ServiceType != "" { + data.ServiceType = string(pr.IllRequest.ServiceInfo.ServiceType) + } + } + if pr.IllRequest.PatronInfo != nil { + if pr.IllRequest.PatronInfo.PatronId != "" { + data.PatronId = pr.IllRequest.PatronInfo.PatronId + } + if pr.IllRequest.PatronInfo.GivenName != "" { + data.PatronName = pr.IllRequest.PatronInfo.GivenName + } + if pr.IllRequest.PatronInfo.Surname != "" { + data.PatronSurname = pr.IllRequest.PatronInfo.Surname + } + } + return data +} + +func RenderPullSlipHTMLWithTemplate(data PullSlipData, templateBody string) (string, error) { + tmpl, err := template.New("pull-slip").Parse(templateBody) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func getStaffNotes(noteList []pr_db.Notification) string { + noteStrings := []string{} + for _, note := range noteList { + if note.Note.Valid { + noteStrings = append(noteStrings, note.Note.String) + } + } + notes := strings.Join(noteStrings, "\n") + if notes == "" { + return DEFAULT_FOR_NO_VALUE + } + return notes +} + +func getLoanConditions(conditionList []pr_db.Notification) string { + conditionStrings := []string{} + for _, note := range conditionList { + if note.Condition.Valid { + conditionStrings = append(conditionStrings, note.Condition.String) + } + } + conditions := strings.Join(conditionStrings, "\n") + if conditions == "" { + return DEFAULT_FOR_NO_VALUE + } + return conditions +} + +func getCallNumber(request pr_db.PatronRequest) string { + callNumberStrings := []string{} + for _, item := range request.Items { + if item.CallNumber != nil && *item.CallNumber != "" { + callNumberStrings = append(callNumberStrings, *item.CallNumber) + } + } + callNumber := strings.Join(callNumberStrings, ", ") + if callNumber == "" { + return DEFAULT_FOR_NO_VALUE + } + return callNumber +} + +func getPickupLocation(request pr_db.PatronRequest) string { + if len(request.IllRequest.RequestedDeliveryInfo) > 0 && request.IllRequest.RequestedDeliveryInfo[0].Address != nil { + address := *request.IllRequest.RequestedDeliveryInfo[0].Address + if address.PhysicalAddress != nil { + return formatPhysicalAddress(address.PhysicalAddress) + } else if address.ElectronicAddress != nil && address.ElectronicAddress.ElectronicAddressData != "" { + return address.ElectronicAddress.ElectronicAddressData + } + } + return DEFAULT_FOR_NO_VALUE +} + +func formatPhysicalAddress(a *iso18626.PhysicalAddress) string { + parts := []string{} + if a.Line1 != "" { + parts = append(parts, a.Line1) + } + if a.Line2 != "" { + parts = append(parts, a.Line2) + } + if a.Locality != "" { + parts = append(parts, a.Locality) + } + if a.PostalCode != "" { + parts = append(parts, a.PostalCode) + } + if a.Region != nil && a.Region.Text != "" { + parts = append(parts, a.Region.Text) + } + if a.Country != nil && a.Country.Text != "" { + parts = append(parts, a.Country.Text) + } + return strings.Join(parts, ", ") +} + +func GetBatchEmailData(fullCount int64, actualCount int, batchQuery string) map[string]string { + return map[string]string{ + "fullCount": fmt.Sprintf("%d", fullCount), + "actualCount": fmt.Sprintf("%d", actualCount), + "batchQuery": batchQuery, + } +} + +func RenderBatchEmailTemplate(value string, placeholders map[string]string) string { + for key, replacement := range placeholders { + value = strings.ReplaceAll(value, "{{"+key+"}}", replacement) + } + return value +} diff --git a/broker/email/email_test.go b/broker/email/email_test.go index fcb9fd3f0..14d2fde52 100644 --- a/broker/email/email_test.go +++ b/broker/email/email_test.go @@ -1,8 +1,14 @@ package email import ( + "strings" "testing" + "time" + pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + "github.com/indexdata/crosslink/iso18626" + "github.com/indexdata/go-utils/utils" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" ) @@ -86,3 +92,319 @@ func TestBuildRawMessage_WithoutAttachment(t *testing.T) { assert.NoError(t, err) assert.NotContains(t, string(raw), "application/pdf") } + +// --------------------------------------------------------------------------- +// RenderPullSlipHTMLWithTemplate +// --------------------------------------------------------------------------- + +func TestRenderPullSlipHTML(t *testing.T) { + template := "
\n Service Type: {{.ServiceType}}
\n Service Level: {{.ServiceLevel}}
\n System Identifier: {{.SystemIdentifier}}
\n Title: {{.Title}}
\n Author: {{.Author}}
\n Publisher: {{.Publisher}}
\n Volume(s): {{.Volume}}
\n Issue: {{.Issue}}
\n Pages: {{.Pages}}
\n
" + html, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ + ServiceType: "Loan", + Title: "Big Shark", + Author: "John Doe", + DueDate: "2026-01-01", + ReturnAddress: "1 Test Street", + SystemIdentifier: "abc123", + }, template) + assert.NoError(t, err) + assert.True(t, strings.Contains(html, "Loan")) + assert.True(t, strings.Contains(html, "John Doe")) + assert.True(t, strings.Contains(html, "Big Shark")) + assert.True(t, strings.Contains(html, "abc123")) +} + +func TestRenderPullSlipHTML_UsesProvidedTemplate(t *testing.T) { + html, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") + + assert.NoError(t, err) + assert.Equal(t, "
REQ-1
", html) +} + +func TestRenderPullSlipHTML_InvalidTemplate(t *testing.T) { + _, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") + assert.Error(t, err) +} + +func TestRenderPullSlipHTML_ExecuteError(t *testing.T) { + _, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") + // Execute on a struct with map-access fails + assert.Error(t, err) +} + +// --------------------------------------------------------------------------- +// GetPullSlipData +// --------------------------------------------------------------------------- + +func TestGetPullSlipData_PopulatesAllAvailableFields(t *testing.T) { + callNumber := "QA76.73.G63" + dueDate := utils.XSDDateTime{Time: time.Date(2026, 8, 15, 9, 30, 0, 0, time.UTC)} + pr := pr_db.PatronRequest{ + RequesterReqID: pgtype.Text{String: "REQ-123", Valid: true}, + Items: []pr_db.PrItem{{ID: "item-1", CallNumber: &callNumber}}, + IllRequest: iso18626.Request{ + BibliographicInfo: iso18626.BibliographicInfo{ + Author: "Jane Doe", + Title: "Distributed Libraries", + Volume: "7", + Issue: "2", + EstimatedNoPages: "18", + SupplierUniqueRecordId: "SYS-456", + }, + PublicationInfo: &iso18626.PublicationInfo{Publisher: "Index Press"}, + ServiceInfo: &iso18626.ServiceInfo{ + ServiceType: iso18626.TypeServiceTypeLoan, + ServiceLevel: &iso18626.TypeSchemeValuePair{Text: "Rush"}, + }, + RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ + {Address: &iso18626.Address{ + PhysicalAddress: &iso18626.PhysicalAddress{ + Line1: "Pickup Desk", + Locality: "Riga", + PostalCode: "LV-1050", + Country: &iso18626.TypeSchemeValuePair{Text: "LV"}, + }, + }}, + }, + PatronInfo: &iso18626.PatronInfo{ + PatronId: "P-789", + GivenName: "Ann", + Surname: "Reader", + }, + }, + IllResponse: iso18626.SupplyingAgencyMessage{ + StatusInfo: iso18626.StatusInfo{DueDate: &dueDate}, + ReturnInfo: &iso18626.ReturnInfo{PhysicalAddress: &iso18626.PhysicalAddress{ + Line1: "Return Room", + Line2: "Shelf B", + Locality: "Riga", + Country: &iso18626.TypeSchemeValuePair{Text: "LV"}, + }}, + }, + } + notes := []pr_db.Notification{ + {Note: pgtype.Text{String: "first note", Valid: true}}, + {Note: pgtype.Text{String: "ignored note", Valid: false}}, + {Note: pgtype.Text{String: "second note", Valid: true}}, + } + conditions := []pr_db.Notification{ + {Condition: pgtype.Text{String: "library use only", Valid: true}}, + {Condition: pgtype.Text{String: "no renewal", Valid: true}}, + } + + data := GetPullSlipData(pr, notes, conditions, "barcode-base64") + + assert.Equal(t, PullSlipData{ + BorrowerName: "", + ReqId: "REQ-123", + PickupLocation: "Pickup Desk, Riga, LV-1050, LV", + Title: "Distributed Libraries", + Author: "Jane Doe", + DueDate: "2026-08-15", + ReturnAddress: "Return Room, Shelf B, Riga, LV", + BarcodeBase64: "barcode-base64", + ServiceType: "Loan", + ServiceLevel: "Rush", + SystemIdentifier: "SYS-456", + Publisher: "Index Press", + Volume: "7", + Issue: "2", + Pages: "18", + StaffNotes: "first note\nsecond note", + CallNumber: "QA76.73.G63", + LoanConditions: "library use only\nno renewal", + PatronName: "Ann", + PatronSurname: "Reader", + PatronId: "P-789", + }, data) +} + +func TestGetPullSlipData_UsesDefaultsWhenOptionalFieldsAreMissing(t *testing.T) { + data := GetPullSlipData(pr_db.PatronRequest{}, nil, nil, DEFAULT_FOR_NO_VALUE) + + assert.Equal(t, PullSlipData{ + BorrowerName: "", + ReqId: "", + PickupLocation: DEFAULT_FOR_NO_VALUE, + Title: DEFAULT_FOR_NO_VALUE, + Author: DEFAULT_FOR_NO_VALUE, + DueDate: DEFAULT_FOR_NO_VALUE, + ReturnAddress: DEFAULT_FOR_NO_VALUE, + BarcodeBase64: DEFAULT_FOR_NO_VALUE, + ServiceType: DEFAULT_FOR_NO_VALUE, + ServiceLevel: DEFAULT_FOR_NO_VALUE, + SystemIdentifier: DEFAULT_FOR_NO_VALUE, + Publisher: DEFAULT_FOR_NO_VALUE, + Volume: DEFAULT_FOR_NO_VALUE, + Issue: DEFAULT_FOR_NO_VALUE, + Pages: DEFAULT_FOR_NO_VALUE, + StaffNotes: DEFAULT_FOR_NO_VALUE, + CallNumber: DEFAULT_FOR_NO_VALUE, + LoanConditions: DEFAULT_FOR_NO_VALUE, + PatronName: DEFAULT_FOR_NO_VALUE, + PatronSurname: DEFAULT_FOR_NO_VALUE, + PatronId: DEFAULT_FOR_NO_VALUE, + }, data) +} + +// ── formatPhysicalAddress ───────────────────────────────────────────────────── + +func TestFormatPhysicalAddress_Full(t *testing.T) { + a := &iso18626.PhysicalAddress{ + Line1: "1 Main St", + Line2: "Floor 2", + Locality: "Springfield", + PostalCode: "12345", + Region: &iso18626.TypeSchemeValuePair{Text: "IL"}, + Country: &iso18626.TypeSchemeValuePair{Text: "US"}, + } + assert.Equal(t, "1 Main St, Floor 2, Springfield, 12345, IL, US", formatPhysicalAddress(a)) +} + +func TestFormatPhysicalAddress_Partial(t *testing.T) { + // Only Line1 and Locality — Region/Country nil, Line2/PostalCode empty + a := &iso18626.PhysicalAddress{ + Line1: "42 Book Rd", + Locality: "Shelbyville", + } + assert.Equal(t, "42 Book Rd, Shelbyville", formatPhysicalAddress(a)) +} + +func TestFormatPhysicalAddress_EmptyRegionText(t *testing.T) { + // Region present but empty Text — should be skipped + a := &iso18626.PhysicalAddress{ + Line1: "1 St", + Region: &iso18626.TypeSchemeValuePair{Text: ""}, + Country: &iso18626.TypeSchemeValuePair{Text: ""}, + } + assert.Equal(t, "1 St", formatPhysicalAddress(a)) +} + +func TestFormatPhysicalAddress_Empty(t *testing.T) { + assert.Equal(t, "", formatPhysicalAddress(&iso18626.PhysicalAddress{})) +} + +// ── getStaffNotes ───────────────────────────────────────────────────────────── + +func TestGetStaffNotes_Empty(t *testing.T) { + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getStaffNotes([]pr_db.Notification{})) +} + +func TestGetStaffNotes_InvalidNotesSkipped(t *testing.T) { + notes := []pr_db.Notification{ + {Note: pgtype.Text{String: "valid note", Valid: true}}, + {Note: pgtype.Text{String: "ignored", Valid: false}}, + } + assert.Equal(t, "valid note", getStaffNotes(notes)) +} + +func TestGetStaffNotes_Multiple(t *testing.T) { + notes := []pr_db.Notification{ + {Note: pgtype.Text{String: "note one", Valid: true}}, + {Note: pgtype.Text{String: "note two", Valid: true}}, + } + assert.Equal(t, "note one\nnote two", getStaffNotes(notes)) +} + +// ── getLoanConditions ───────────────────────────────────────────────────────── + +func TestGetLoanConditions_Empty(t *testing.T) { + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getLoanConditions([]pr_db.Notification{})) +} + +func TestGetLoanConditions_InvalidSkipped(t *testing.T) { + conditions := []pr_db.Notification{ + {Condition: pgtype.Text{String: "library use only", Valid: true}}, + {Condition: pgtype.Text{String: "ignored", Valid: false}}, + } + assert.Equal(t, "library use only", getLoanConditions(conditions)) +} + +func TestGetLoanConditions_Multiple(t *testing.T) { + conditions := []pr_db.Notification{ + {Condition: pgtype.Text{String: "no photocopying", Valid: true}}, + {Condition: pgtype.Text{String: "in-library use", Valid: true}}, + } + assert.Equal(t, "no photocopying\nin-library use", getLoanConditions(conditions)) +} + +// ── getCallNumber ───────────────────────────────────────────────────────────── + +func TestGetCallNumber_Empty(t *testing.T) { + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getCallNumber(pr_db.PatronRequest{})) +} + +func TestGetCallNumber_NilCallNumber(t *testing.T) { + pr := pr_db.PatronRequest{Items: []pr_db.PrItem{{ID: "i1", CallNumber: nil}}} + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getCallNumber(pr)) +} + +func TestGetCallNumber_EmptyCallNumber(t *testing.T) { + empty := "" + pr := pr_db.PatronRequest{Items: []pr_db.PrItem{{ID: "i1", CallNumber: &empty}}} + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getCallNumber(pr)) +} + +func TestGetCallNumber_Multiple(t *testing.T) { + cn1, cn2 := "QA76", "PR9199" + pr := pr_db.PatronRequest{Items: []pr_db.PrItem{ + {ID: "i1", CallNumber: &cn1}, + {ID: "i2", CallNumber: &cn2}, + }} + assert.Equal(t, "QA76, PR9199", getCallNumber(pr)) +} + +// ── getPickupLocation ───────────────────────────────────────────────────────── + +func TestGetPickupLocation_NoDeliveryInfo(t *testing.T) { + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr_db.PatronRequest{})) +} + +func TestGetPickupLocation_NilAddress(t *testing.T) { + pr := pr_db.PatronRequest{ + IllRequest: iso18626.Request{ + RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{{Address: nil}}, + }, + } + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr)) +} + +func TestGetPickupLocation_PhysicalAddress(t *testing.T) { + pr := pr_db.PatronRequest{ + IllRequest: iso18626.Request{ + RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ + {Address: &iso18626.Address{ + PhysicalAddress: &iso18626.PhysicalAddress{Line1: "Pickup Desk"}, + }}, + }, + }, + } + assert.Equal(t, "Pickup Desk", getPickupLocation(pr)) +} + +func TestGetPickupLocation_ElectronicAddress(t *testing.T) { + pr := pr_db.PatronRequest{ + IllRequest: iso18626.Request{ + RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ + {Address: &iso18626.Address{ + ElectronicAddress: &iso18626.ElectronicAddress{ + ElectronicAddressData: "patron@library.org", + }, + }}, + }, + }, + } + assert.Equal(t, "patron@library.org", getPickupLocation(pr)) +} + +func TestGetPickupLocation_AddressWithNoUsableFields(t *testing.T) { + // Address present but neither PhysicalAddress nor a non-empty ElectronicAddressData + pr := pr_db.PatronRequest{ + IllRequest: iso18626.Request{ + RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ + {Address: &iso18626.Address{}}, + }, + }, + } + assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr)) +} diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index dd5476f04..7d885fda4 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -759,6 +759,9 @@ components: description: Version of the state model in SemVer selector: $ref: '#/components/schemas/StateModelSelector' + pulllslipPdfTemplateLabel: + type: string + description: Template label used to resolve the PDF pullslip template for this state model. states: type: array description: A list of all allowed states diff --git a/broker/patron_request/api/api-handler_test.go b/broker/patron_request/api/api-handler_test.go index b22103934..67af42a6f 100644 --- a/broker/patron_request/api/api-handler_test.go +++ b/broker/patron_request/api/api-handler_test.go @@ -1100,7 +1100,7 @@ func TestGetStateModelTemplates(t *testing.T) { var templates []proapi.CreateTemplate err := json.Unmarshal(rr.Body.Bytes(), &templates) assert.NoError(t, err) - assert.Len(t, templates, 5) + assert.Len(t, templates, 6) labels := make([]string, 0, len(templates)) for _, template := range templates { assert.NotEmpty(t, template.Title) @@ -1113,6 +1113,7 @@ func TestGetStateModelTemplates(t *testing.T) { "cancelled-notification", "new-supply-request-notification", "pullslip-email", + "pullslip-pdf", }, labels) } diff --git a/broker/patron_request/service/action.go b/broker/patron_request/service/action.go index c3098bc85..6382004db 100644 --- a/broker/patron_request/service/action.go +++ b/broker/patron_request/service/action.go @@ -1711,7 +1711,7 @@ func (a *PatronRequestActionService) sendEmailNotification(ctx common.ExtendedCo if len(recipients) == 0 { result.Note = "no recipients found for patron" } else { - sendErr := a.createAndSendEmail(ctx, symbol, from, recipients, *params.AutoActionParams.TemplateLabel, proapi.ModelActionParamsSendToPatron) + sendErr := a.createAndSendEmail(ctx, pr, symbol, from, recipients, *params.AutoActionParams.TemplateLabel, proapi.ModelActionParamsSendToPatron) if sendErr != nil { return logNotificationErrorAndReturnSuccess(ctx, pr, "error sending email to patron", sendErr) } @@ -1731,7 +1731,7 @@ func (a *PatronRequestActionService) sendEmailNotification(ctx common.ExtendedCo } result.Note += "no recipients found for staff" } else { - sendErr := a.createAndSendEmail(ctx, symbol, from, recipients, *params.AutoActionParams.TemplateLabel, proapi.ModelActionParamsSendToStaff) + sendErr := a.createAndSendEmail(ctx, pr, symbol, from, recipients, *params.AutoActionParams.TemplateLabel, proapi.ModelActionParamsSendToStaff) if sendErr != nil { return logNotificationErrorAndReturnSuccess(ctx, pr, "error sending email to staff", sendErr) } @@ -1742,7 +1742,7 @@ func (a *PatronRequestActionService) sendEmailNotification(ctx common.ExtendedCo return actionExecutionResult{status: events.EventStatusSuccess, result: &result, pr: pr} } -func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedContext, symbol string, from string, recipients []string, label string, audience proapi.ModelActionParamsSendTo) error { +func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedContext, pr pr_db.PatronRequest, symbol string, from string, recipients []string, label string, audience proapi.ModelActionParamsSendTo) error { template, err := a.prRepo.GetTemplateByPurposeAudienceLabelAndOwner(ctx, pr_db.GetTemplateByPurposeAudienceLabelAndOwnerParams{ Purpose: string(proapi.Email), Owner: symbol, @@ -1752,10 +1752,27 @@ func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedConte if err != nil { return err } + body := template.Body + if template.ContentType == string(proapi.Html) { + notes, _, ifErr := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindNote)}) + if ifErr != nil { + return ifErr + } + conditions, _, ifErr := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindCondition)}) + if ifErr != nil { + return ifErr + } + data := email.GetPullSlipData(pr, notes, conditions, email.DEFAULT_FOR_NO_VALUE) + html, ifErr := email.RenderPullSlipHTMLWithTemplate(data, template.Body) + if ifErr != nil { + return ifErr + } + body = html + } emailData := email.EmailData{ To: recipients, Subject: template.Subject.String, - Body: template.Body, + Body: body, IsHTML: template.ContentType == string(proapi.Html), IncludePdf: false, } diff --git a/broker/patron_request/service/action_mapping.go b/broker/patron_request/service/action_mapping.go index faa5965a2..f92018bb6 100644 --- a/broker/patron_request/service/action_mapping.go +++ b/broker/patron_request/service/action_mapping.go @@ -27,6 +27,14 @@ func (r *ActionMappingService) GetActionMapping(request iso18626.Request) (*Acti return mapping, err } +func (r *ActionMappingService) GetStateModelForRequest(request iso18626.Request) (*proapi.StateModel, error) { + name, _, err := r.ResolveActionMapping(request) + if err != nil { + return nil, err + } + return r.getStateModelService().GetStateModel(name) +} + // ResolveActionMapping selects the state model once and returns both the // persisted model key and the effective mapping for the request's service type. func (r *ActionMappingService) ResolveActionMapping(request iso18626.Request) (string, *ActionMapping, error) { diff --git a/broker/patron_request/service/action_test.go b/broker/patron_request/service/action_test.go index 8cf4c5a67..992372d4c 100644 --- a/broker/patron_request/service/action_test.go +++ b/broker/patron_request/service/action_test.go @@ -3785,6 +3785,8 @@ func TestCreateAndSendEmail(t *testing.T) { Subject: pgtype.Text{String: "Your request", Valid: true}, } + pr := pr_db.PatronRequest{IllRequest: iso18626.Request{}} + tests := []struct { name string from string @@ -3859,7 +3861,7 @@ func TestCreateAndSendEmail(t *testing.T) { tc.setupEmail(mockEmail) svc := newActionServiceWithEmail(mockPrRepo, mockEmail) - err := svc.createAndSendEmail(appCtx, symbol, tc.from, tc.recipients, label, audience) + err := svc.createAndSendEmail(appCtx, pr, symbol, tc.from, tc.recipients, label, audience) if tc.wantErrSubstr == "" { assert.NoError(t, err) diff --git a/broker/patron_request/service/statemodel_test.go b/broker/patron_request/service/statemodel_test.go index d5b47926c..d94e5d03c 100644 --- a/broker/patron_request/service/statemodel_test.go +++ b/broker/patron_request/service/statemodel_test.go @@ -219,6 +219,38 @@ func TestDefaultIncludesLocalSupplyRequesterState(t *testing.T) { assert.NotEqual(t, -1, stateIndex) } +func TestReturnablesPullslipPdfTemplateLabel(t *testing.T) { + model, err := LoadStateModelByName("returnables") + if !assert.NoError(t, err) || !assert.NotNil(t, model) { + return + } + + if assert.NotNil(t, model.PulllslipPdfTemplateLabel) { + assert.Equal(t, "pullslip-pdf", *model.PulllslipPdfTemplateLabel) + } +} + +func TestStateModelTemplateDefaultsIncludePullslipPdf(t *testing.T) { + templates := GetStateModelTemplateDefaults() + + idx := slices.IndexFunc(templates, func(template proapi.CreateTemplate) bool { + return slices.Contains(template.Labels, "pullslip-pdf") + }) + if !assert.NotEqual(t, -1, idx) { + return + } + + template := templates[idx] + assert.Equal(t, "Pullslip PDF template", template.Title) + assert.Equal(t, proapi.Pullslip, template.Purpose) + assert.Equal(t, proapi.Html, template.ContentType) + if assert.NotNil(t, template.Audience) { + assert.Equal(t, proapi.TemplateAudienceStaff, *template.Audience) + } + assert.Contains(t, template.Body, "{{.BarcodeBase64}}") + assert.Contains(t, template.Body, "{{.ReqId}}") +} + func TestDefaultInvalidPatronStateIsEditableAndNeedsAttention(t *testing.T) { model, err := LoadStateModelByName("default") if !assert.NoError(t, err) || !assert.NotNil(t, model) { diff --git a/broker/pullslip/api/api_handler_test.go b/broker/pullslip/api/api_handler_test.go index db5f7e0bd..4c809ecef 100644 --- a/broker/pullslip/api/api_handler_test.go +++ b/broker/pullslip/api/api_handler_test.go @@ -5,12 +5,14 @@ import ( "errors" "net/http" "net/http/httptest" + "slices" "strings" "testing" "github.com/indexdata/cql-go/pgcql" "github.com/indexdata/crosslink/broker/common" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + prservice "github.com/indexdata/crosslink/broker/patron_request/service" ps_db "github.com/indexdata/crosslink/broker/pullslip/db" psoapi "github.com/indexdata/crosslink/broker/pullslip/oapi" "github.com/indexdata/crosslink/broker/tenant" @@ -52,6 +54,17 @@ func (m *MockPrRepo) GetNotificationsByPrId(ctx common.ExtendedContext, params p return args.Get(0).([]pr_db.Notification), args.Get(1).(int64), args.Error(2) } +func (m *MockPrRepo) GetTemplateByPurposeAudienceLabelAndOwner(_ common.ExtendedContext, params pr_db.GetTemplateByPurposeAudienceLabelAndOwnerParams) (pr_db.Template, error) { + for _, t := range prservice.GetStateModelTemplateDefaults() { + if slices.Contains(t.Labels, params.Label) { + return pr_db.Template{ + Body: t.Body, + }, nil + } + } + return pr_db.Template{}, nil +} + // ── helpers ─────────────────────────────────────────────────────────────────── var sym = "ISIL:TEST" diff --git a/broker/pullslip/service/pdf.go b/broker/pullslip/service/pdf.go index 9c47a7e6c..f6dc7e570 100644 --- a/broker/pullslip/service/pdf.go +++ b/broker/pullslip/service/pdf.go @@ -4,60 +4,36 @@ import ( "bytes" _ "embed" "encoding/base64" - "html/template" + "errors" "image/png" - "strings" "github.com/boombuler/barcode" "github.com/boombuler/barcode/code128" "github.com/carlos7ags/folio/document" "github.com/carlos7ags/folio/reader" "github.com/indexdata/crosslink/broker/common" + "github.com/indexdata/crosslink/broker/email" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" - "github.com/indexdata/crosslink/iso18626" + "github.com/indexdata/crosslink/broker/patron_request/proapi" + prservice "github.com/indexdata/crosslink/broker/patron_request/service" ) -const DEFAULT_FOR_NO_VALUE = "n/a" -const DATE_LAYOUT = "2006-01-02" - type PdfService interface { GeneratePdfPullSlipForPrs(ctx common.ExtendedContext, prs []pr_db.PatronRequest) ([]byte, error) } type PdfServiceImpl struct { - prRepo pr_db.PrRepo + prRepo pr_db.PrRepo + actionMappingService prservice.ActionMappingService } func NewPdfService(prRepo pr_db.PrRepo) PdfService { return &PdfServiceImpl{ - prRepo: prRepo, + prRepo: prRepo, + actionMappingService: prservice.ActionMappingService{SMService: &prservice.StateModelService{}}, } } -type PullSlipData struct { - BorrowerName string - ReqId string - PickupLocation string - Title string - Author string - DueDate string - ReturnAddress string - BarcodeBase64 string - ServiceType string - ServiceLevel string - SystemIdentifier string - Publisher string - Volume string - Issue string - Pages string - StaffNotes string - CallNumber string - LoanConditions string -} - -//go:embed pull_slip_template.html -var pullSlipTemplate string - func (p *PdfServiceImpl) GeneratePdfPullSlipForPrs(ctx common.ExtendedContext, prs []pr_db.PatronRequest) ([]byte, error) { pdfs := []*reader.PdfReader{} for _, pr := range prs { @@ -69,7 +45,7 @@ func (p *PdfServiceImpl) GeneratePdfPullSlipForPrs(ctx common.ExtendedContext, p if err != nil { return []byte{}, err } - pdf, err := p.GeneratePdfPullSlip(pr, notes, conditions) + pdf, err := p.GeneratePdfPullSlip(ctx, pr, notes, conditions) if err != nil { return []byte{}, err } @@ -90,68 +66,20 @@ func (p *PdfServiceImpl) GeneratePdfPullSlipForPrs(ctx common.ExtendedContext, p return buf.Bytes(), nil } -func (p *PdfServiceImpl) GeneratePdfPullSlip(pr pr_db.PatronRequest, notes []pr_db.Notification, conditions []pr_db.Notification) ([]byte, error) { +func (p *PdfServiceImpl) GeneratePdfPullSlip(ctx common.ExtendedContext, pr pr_db.PatronRequest, notes []pr_db.Notification, conditions []pr_db.Notification) ([]byte, error) { barcodeData, err := getBarcodeBase64(pr.RequesterReqID.String) if err != nil { return nil, err } - data := PullSlipData{ - ReqId: pr.RequesterReqID.String, - PickupLocation: getPickupLocation(pr), - Title: DEFAULT_FOR_NO_VALUE, - Author: DEFAULT_FOR_NO_VALUE, - DueDate: DEFAULT_FOR_NO_VALUE, - ReturnAddress: DEFAULT_FOR_NO_VALUE, - BarcodeBase64: barcodeData, - ServiceType: DEFAULT_FOR_NO_VALUE, - ServiceLevel: DEFAULT_FOR_NO_VALUE, - SystemIdentifier: DEFAULT_FOR_NO_VALUE, - Publisher: DEFAULT_FOR_NO_VALUE, - Volume: DEFAULT_FOR_NO_VALUE, - Issue: DEFAULT_FOR_NO_VALUE, - Pages: DEFAULT_FOR_NO_VALUE, - StaffNotes: getStaffNotes(notes), - CallNumber: getCallNumber(pr), - LoanConditions: getLoanConditions(conditions), - } - if pr.IllRequest.BibliographicInfo.Author != "" { - data.Author = pr.IllRequest.BibliographicInfo.Author - } - if pr.IllRequest.BibliographicInfo.Title != "" { - data.Title = pr.IllRequest.BibliographicInfo.Title - } - if pr.IllRequest.BibliographicInfo.Volume != "" { - data.Volume = pr.IllRequest.BibliographicInfo.Volume - } - if pr.IllRequest.BibliographicInfo.Issue != "" { - data.Issue = pr.IllRequest.BibliographicInfo.Issue - } - if pr.IllRequest.BibliographicInfo.EstimatedNoPages != "" { - data.Pages = pr.IllRequest.BibliographicInfo.EstimatedNoPages - } - if pr.IllRequest.BibliographicInfo.SupplierUniqueRecordId != "" { - data.SystemIdentifier = pr.IllRequest.BibliographicInfo.SupplierUniqueRecordId - } - if pr.IllRequest.PublicationInfo != nil && pr.IllRequest.PublicationInfo.Publisher != "" { - data.Publisher = pr.IllRequest.PublicationInfo.Publisher - } - if pr.IllResponse.StatusInfo.DueDate != nil { - data.DueDate = pr.IllResponse.StatusInfo.DueDate.Format(DATE_LAYOUT) - } - if pr.IllResponse.ReturnInfo != nil && pr.IllResponse.ReturnInfo.PhysicalAddress != nil { - data.ReturnAddress = formatPhysicalAddress(pr.IllResponse.ReturnInfo.PhysicalAddress) - } - if pr.IllRequest.ServiceInfo != nil { - if pr.IllRequest.ServiceInfo.ServiceLevel != nil && pr.IllRequest.ServiceInfo.ServiceLevel.Text != "" { - data.ServiceLevel = pr.IllRequest.ServiceInfo.ServiceLevel.Text - } - if pr.IllRequest.ServiceInfo.ServiceType != "" { - data.ServiceType = string(pr.IllRequest.ServiceInfo.ServiceType) - } + templateBody, err := p.getTemplateForPatronRequest(ctx, pr) + if err != nil { + return []byte{}, err } + doc := document.NewDocument(document.PageSizeA4) - html, err := renderPullSlipHTML(data) + data := email.GetPullSlipData(pr, notes, conditions, barcodeData) + html, err := email.RenderPullSlipHTMLWithTemplate(data, templateBody) if err != nil { return nil, err } @@ -164,16 +92,31 @@ func (p *PdfServiceImpl) GeneratePdfPullSlip(pr pr_db.PatronRequest, notes []pr_ return doc.ToBytes() } -func renderPullSlipHTML(data PullSlipData) (string, error) { - tmpl, err := template.New("pull-slip").Parse(pullSlipTemplate) +func (p *PdfServiceImpl) getTemplateForPatronRequest(ctx common.ExtendedContext, pr pr_db.PatronRequest) (string, error) { + stateModel, err := p.actionMappingService.GetStateModelForRequest(pr.IllRequest) if err != nil { return "", err } - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { + if stateModel.PulllslipPdfTemplateLabel == nil || *stateModel.PulllslipPdfTemplateLabel == "" { + return "", errors.New("pulllslipPdfTemplateLabel field is required") + } + owner := pr.RequesterSymbol + if pr.Side == prservice.SideLending { + owner = pr.SupplierSymbol + } + pdfTemplate, err := p.prRepo.GetTemplateByPurposeAudienceLabelAndOwner(ctx, pr_db.GetTemplateByPurposeAudienceLabelAndOwnerParams{ + Owner: owner.String, + Purpose: string(proapi.Pullslip), + Label: *stateModel.PulllslipPdfTemplateLabel, + Audience: string(proapi.ModelActionParamsSendToStaff), + }) + if err != nil { return "", err } - return buf.String(), nil + if pdfTemplate.Body == "" { + return "", errors.New("invalid pullslip pdf template, body field is required") + } + return pdfTemplate.Body, nil } // barcodeWidth calculates a suitable barcode pixel width based on the number @@ -211,80 +154,3 @@ func getBarcodeBase64(data string) (string, error) { } return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } - -func formatPhysicalAddress(a *iso18626.PhysicalAddress) string { - parts := []string{} - if a.Line1 != "" { - parts = append(parts, a.Line1) - } - if a.Line2 != "" { - parts = append(parts, a.Line2) - } - if a.Locality != "" { - parts = append(parts, a.Locality) - } - if a.PostalCode != "" { - parts = append(parts, a.PostalCode) - } - if a.Region != nil && a.Region.Text != "" { - parts = append(parts, a.Region.Text) - } - if a.Country != nil && a.Country.Text != "" { - parts = append(parts, a.Country.Text) - } - return strings.Join(parts, ", ") -} - -func getStaffNotes(noteList []pr_db.Notification) string { - noteStrings := []string{} - for _, note := range noteList { - if note.Note.Valid { - noteStrings = append(noteStrings, note.Note.String) - } - } - notes := strings.Join(noteStrings, "\n") - if notes == "" { - return DEFAULT_FOR_NO_VALUE - } - return notes -} - -func getLoanConditions(conditionList []pr_db.Notification) string { - conditionStrings := []string{} - for _, note := range conditionList { - if note.Condition.Valid { - conditionStrings = append(conditionStrings, note.Condition.String) - } - } - conditions := strings.Join(conditionStrings, "\n") - if conditions == "" { - return DEFAULT_FOR_NO_VALUE - } - return conditions -} - -func getCallNumber(request pr_db.PatronRequest) string { - callNumberStrings := []string{} - for _, item := range request.Items { - if item.CallNumber != nil && *item.CallNumber != "" { - callNumberStrings = append(callNumberStrings, *item.CallNumber) - } - } - callNumber := strings.Join(callNumberStrings, ", ") - if callNumber == "" { - return DEFAULT_FOR_NO_VALUE - } - return callNumber -} - -func getPickupLocation(request pr_db.PatronRequest) string { - if len(request.IllRequest.RequestedDeliveryInfo) > 0 && request.IllRequest.RequestedDeliveryInfo[0].Address != nil { - address := *request.IllRequest.RequestedDeliveryInfo[0].Address - if address.PhysicalAddress != nil { - return formatPhysicalAddress(address.PhysicalAddress) - } else if address.ElectronicAddress != nil && address.ElectronicAddress.ElectronicAddressData != "" { - return address.ElectronicAddress.ElectronicAddressData - } - } - return DEFAULT_FOR_NO_VALUE -} diff --git a/broker/pullslip/service/pdf_test.go b/broker/pullslip/service/pdf_test.go index e4fac4505..2eb1bd19b 100644 --- a/broker/pullslip/service/pdf_test.go +++ b/broker/pullslip/service/pdf_test.go @@ -6,12 +6,13 @@ import ( "encoding/base64" "errors" "image/png" - "strings" + "slices" "testing" "time" "github.com/indexdata/crosslink/broker/common" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" + prservice "github.com/indexdata/crosslink/broker/patron_request/service" "github.com/indexdata/crosslink/iso18626" "github.com/indexdata/go-utils/utils" "github.com/jackc/pgx/v5/pgtype" @@ -52,47 +53,9 @@ func TestGetBarcodeBase64(t *testing.T) { assert.Equal(t, 67, bounds.Dy()) } -func TestRenderPullSlipHTML(t *testing.T) { - html, err := renderPullSlipHTML(PullSlipData{ - ReqId: "REQ-123", - PickupLocation: "Main Library", - Title: "Big Shark", - Author: "John Doe", - DueDate: "2026-01-01", - ReturnAddress: "1 Test Street", - BarcodeBase64: "abc123", - }) - assert.NoError(t, err) - assert.True(t, strings.Contains(html, "REQ-123")) - assert.True(t, strings.Contains(html, "Main Library")) - assert.True(t, strings.Contains(html, "data:image/png;base64,abc123")) -} - -func TestRenderPullSlipHTML_InvalidTemplate(t *testing.T) { - // Temporarily swap pullSlipTemplate with an invalid one - orig := pullSlipTemplate - defer func() { pullSlipTemplate = orig }() - pullSlipTemplate = `{{.Unclosed` - - _, err := renderPullSlipHTML(PullSlipData{ReqId: "X"}) - assert.Error(t, err) -} - -func TestRenderPullSlipHTML_ExecuteError(t *testing.T) { - // A template that calls a function on a field that panics/errors at execute time - orig := pullSlipTemplate - defer func() { pullSlipTemplate = orig }() - // Use a template that references a non-existent function to trigger execute error - // The only reliable way in Go templates: call.option "missingkey=error" with unknown key on a map - pullSlipTemplate = `{{index . "nonexistent"}}` - - _, err := renderPullSlipHTML(PullSlipData{ReqId: "X"}) - // Execute on a struct with map-access fails - assert.Error(t, err) -} - func TestGeneratePdfPullSlip_Defaults(t *testing.T) { - svc := &PdfServiceImpl{} + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ RequesterReqID: pgtype.Text{ String: "REQ-DEFAULTS", @@ -100,7 +63,7 @@ func TestGeneratePdfPullSlip_Defaults(t *testing.T) { }, // No bibliographic info — all fields should fall back to DEFAULT_FOR_NO_VALUE } - pdfBytes, err := svc.GeneratePdfPullSlip(pr, []pr_db.Notification{}, []pr_db.Notification{}) + pdfBytes, err := svc.GeneratePdfPullSlip(appCtx, pr, []pr_db.Notification{}, []pr_db.Notification{}) assert.NoError(t, err) assert.NotEmpty(t, pdfBytes) // PDF magic bytes: %PDF @@ -108,7 +71,8 @@ func TestGeneratePdfPullSlip_Defaults(t *testing.T) { } func TestGeneratePdfPullSlip_WithBibliographicInfo(t *testing.T) { - svc := &PdfServiceImpl{} + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ ID: "REQ-BIB", RequesterReqID: pgtype.Text{ @@ -122,7 +86,7 @@ func TestGeneratePdfPullSlip_WithBibliographicInfo(t *testing.T) { }, }, } - pdfBytes, err := svc.GeneratePdfPullSlip(pr, []pr_db.Notification{}, []pr_db.Notification{}) + pdfBytes, err := svc.GeneratePdfPullSlip(appCtx, pr, []pr_db.Notification{}, []pr_db.Notification{}) assert.NoError(t, err) assert.NotEmpty(t, pdfBytes) assert.Equal(t, "%PDF", string(pdfBytes[:4])) @@ -132,7 +96,8 @@ func TestGeneratePdfPullSlip_FullData(t *testing.T) { callNumber := "QA76.9.A25" dueDate := utils.XSDDateTime{Time: time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)} - svc := &PdfServiceImpl{} + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ ID: "REQ-FULL", RequesterReqID: pgtype.Text{ @@ -195,7 +160,7 @@ func TestGeneratePdfPullSlip_FullData(t *testing.T) { {Condition: pgtype.Text{String: "No photocopying", Valid: true}}, } - pdfBytes, err := svc.GeneratePdfPullSlip(pr, notes, conditions) + pdfBytes, err := svc.GeneratePdfPullSlip(appCtx, pr, notes, conditions) assert.NoError(t, err) assert.NotEmpty(t, pdfBytes) assert.Equal(t, "%PDF", string(pdfBytes[:4])) @@ -215,187 +180,53 @@ func TestGeneratePdfPullSlip_BarcodeError(t *testing.T) { Valid: true, }, } - _, err := svc.GeneratePdfPullSlip(pr, []pr_db.Notification{}, []pr_db.Notification{}) + _, err := svc.GeneratePdfPullSlip(appCtx, pr, []pr_db.Notification{}, []pr_db.Notification{}) assert.Error(t, err) } func TestGeneratePdfPullSlip_TemplateError(t *testing.T) { - orig := pullSlipTemplate - defer func() { pullSlipTemplate = orig }() - pullSlipTemplate = `{{.Unclosed` - - svc := &PdfServiceImpl{} + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ ID: "REQ-X", RequesterReqID: pgtype.Text{ String: "REQ-X", Valid: true, }, + RequesterSymbol: pgtype.Text{String: "invalid", Valid: true}, } - _, err := svc.GeneratePdfPullSlip(pr, []pr_db.Notification{}, []pr_db.Notification{}) + _, err := svc.GeneratePdfPullSlip(appCtx, pr, []pr_db.Notification{}, []pr_db.Notification{}) assert.Error(t, err) } -// ── formatPhysicalAddress ───────────────────────────────────────────────────── - -func TestFormatPhysicalAddress_Full(t *testing.T) { - a := &iso18626.PhysicalAddress{ - Line1: "1 Main St", - Line2: "Floor 2", - Locality: "Springfield", - PostalCode: "12345", - Region: &iso18626.TypeSchemeValuePair{Text: "IL"}, - Country: &iso18626.TypeSchemeValuePair{Text: "US"}, - } - assert.Equal(t, "1 Main St, Floor 2, Springfield, 12345, IL, US", formatPhysicalAddress(a)) -} - -func TestFormatPhysicalAddress_Partial(t *testing.T) { - // Only Line1 and Locality — Region/Country nil, Line2/PostalCode empty - a := &iso18626.PhysicalAddress{ - Line1: "42 Book Rd", - Locality: "Shelbyville", - } - assert.Equal(t, "42 Book Rd, Shelbyville", formatPhysicalAddress(a)) -} - -func TestFormatPhysicalAddress_EmptyRegionText(t *testing.T) { - // Region present but empty Text — should be skipped - a := &iso18626.PhysicalAddress{ - Line1: "1 St", - Region: &iso18626.TypeSchemeValuePair{Text: ""}, - Country: &iso18626.TypeSchemeValuePair{Text: ""}, - } - assert.Equal(t, "1 St", formatPhysicalAddress(a)) -} - -func TestFormatPhysicalAddress_Empty(t *testing.T) { - assert.Equal(t, "", formatPhysicalAddress(&iso18626.PhysicalAddress{})) -} - -// ── getStaffNotes ───────────────────────────────────────────────────────────── - -func TestGetStaffNotes_Empty(t *testing.T) { - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getStaffNotes([]pr_db.Notification{})) -} - -func TestGetStaffNotes_InvalidNotesSkipped(t *testing.T) { - notes := []pr_db.Notification{ - {Note: pgtype.Text{String: "valid note", Valid: true}}, - {Note: pgtype.Text{String: "ignored", Valid: false}}, - } - assert.Equal(t, "valid note", getStaffNotes(notes)) -} - -func TestGetStaffNotes_Multiple(t *testing.T) { - notes := []pr_db.Notification{ - {Note: pgtype.Text{String: "note one", Valid: true}}, - {Note: pgtype.Text{String: "note two", Valid: true}}, - } - assert.Equal(t, "note one\nnote two", getStaffNotes(notes)) -} - -// ── getLoanConditions ───────────────────────────────────────────────────────── - -func TestGetLoanConditions_Empty(t *testing.T) { - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getLoanConditions([]pr_db.Notification{})) -} - -func TestGetLoanConditions_InvalidSkipped(t *testing.T) { - conditions := []pr_db.Notification{ - {Condition: pgtype.Text{String: "library use only", Valid: true}}, - {Condition: pgtype.Text{String: "ignored", Valid: false}}, - } - assert.Equal(t, "library use only", getLoanConditions(conditions)) -} - -func TestGetLoanConditions_Multiple(t *testing.T) { - conditions := []pr_db.Notification{ - {Condition: pgtype.Text{String: "no photocopying", Valid: true}}, - {Condition: pgtype.Text{String: "in-library use", Valid: true}}, - } - assert.Equal(t, "no photocopying\nin-library use", getLoanConditions(conditions)) -} - -// ── getCallNumber ───────────────────────────────────────────────────────────── - -func TestGetCallNumber_Empty(t *testing.T) { - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getCallNumber(pr_db.PatronRequest{})) -} - -func TestGetCallNumber_NilCallNumber(t *testing.T) { - pr := pr_db.PatronRequest{Items: []pr_db.PrItem{{ID: "i1", CallNumber: nil}}} - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getCallNumber(pr)) -} - -func TestGetCallNumber_EmptyCallNumber(t *testing.T) { - empty := "" - pr := pr_db.PatronRequest{Items: []pr_db.PrItem{{ID: "i1", CallNumber: &empty}}} - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getCallNumber(pr)) -} - -func TestGetCallNumber_Multiple(t *testing.T) { - cn1, cn2 := "QA76", "PR9199" - pr := pr_db.PatronRequest{Items: []pr_db.PrItem{ - {ID: "i1", CallNumber: &cn1}, - {ID: "i2", CallNumber: &cn2}, - }} - assert.Equal(t, "QA76, PR9199", getCallNumber(pr)) -} - -// ── getPickupLocation ───────────────────────────────────────────────────────── - -func TestGetPickupLocation_NoDeliveryInfo(t *testing.T) { - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr_db.PatronRequest{})) -} - -func TestGetPickupLocation_NilAddress(t *testing.T) { - pr := pr_db.PatronRequest{ - IllRequest: iso18626.Request{ - RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{{Address: nil}}, - }, - } - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr)) -} - -func TestGetPickupLocation_PhysicalAddress(t *testing.T) { - pr := pr_db.PatronRequest{ - IllRequest: iso18626.Request{ - RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ - {Address: &iso18626.Address{ - PhysicalAddress: &iso18626.PhysicalAddress{Line1: "Pickup Desk"}, - }}, - }, - }, - } - assert.Equal(t, "Pickup Desk", getPickupLocation(pr)) -} - -func TestGetPickupLocation_ElectronicAddress(t *testing.T) { +func TestGeneratePdfPullSlip_TemplateEmpty(t *testing.T) { + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ - IllRequest: iso18626.Request{ - RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ - {Address: &iso18626.Address{ - ElectronicAddress: &iso18626.ElectronicAddress{ - ElectronicAddressData: "patron@library.org", - }, - }}, - }, + ID: "REQ-X", + RequesterReqID: pgtype.Text{ + String: "REQ-X", + Valid: true, }, + RequesterSymbol: pgtype.Text{String: "empty", Valid: true}, } - assert.Equal(t, "patron@library.org", getPickupLocation(pr)) + _, err := svc.GeneratePdfPullSlip(appCtx, pr, []pr_db.Notification{}, []pr_db.Notification{}) + assert.Error(t, err) } -func TestGetPickupLocation_AddressWithNoUsableFields(t *testing.T) { - // Address present but neither PhysicalAddress nor a non-empty ElectronicAddressData +func TestGeneratePdfPullSlip_TemplateDbError(t *testing.T) { + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ - IllRequest: iso18626.Request{ - RequestedDeliveryInfo: []iso18626.RequestedDeliveryInfo{ - {Address: &iso18626.Address{}}, - }, + ID: "REQ-X", + RequesterReqID: pgtype.Text{ + String: "REQ-X", + Valid: true, }, + RequesterSymbol: pgtype.Text{String: "error", Valid: true}, } - assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr)) + _, err := svc.GeneratePdfPullSlip(appCtx, pr, []pr_db.Notification{}, []pr_db.Notification{}) + assert.Error(t, err) } // ── GeneratePdfPullSlipForPrs ───────────────────────────────────────────────── @@ -415,6 +246,26 @@ func (m *mockPrRepo) GetNotificationsByPrId(_ common.ExtendedContext, params pr_ return m.conditions, int64(len(m.conditions)), m.condErr } +func (m *mockPrRepo) GetTemplateByPurposeAudienceLabelAndOwner(_ common.ExtendedContext, params pr_db.GetTemplateByPurposeAudienceLabelAndOwnerParams) (pr_db.Template, error) { + if params.Owner == "invalid" { + return pr_db.Template{Body: "{{.Unclosed"}, nil + } + if params.Owner == "empty" { + return pr_db.Template{}, nil + } + if params.Owner == "error" { + return pr_db.Template{}, errors.New("template db error") + } + for _, t := range prservice.GetStateModelTemplateDefaults() { + if slices.Contains(t.Labels, params.Label) { + return pr_db.Template{ + Body: t.Body, + }, nil + } + } + return pr_db.Template{}, nil +} + func newSvcWithMock(repo pr_db.PrRepo) *PdfServiceImpl { return &PdfServiceImpl{prRepo: repo} } @@ -462,7 +313,8 @@ func TestGeneratePdfPullSlipForPrs_ConditionError(t *testing.T) { // ── ServiceInfo edge cases ──────────────────────────────────────────────────── func TestGeneratePdfPullSlip_ServiceInfoEmptyServiceLevel(t *testing.T) { - svc := &PdfServiceImpl{} + repo := &mockPrRepo{} + svc := newSvcWithMock(repo) pr := pr_db.PatronRequest{ RequesterReqID: pgtype.Text{String: "REQ-SVC", Valid: true}, IllRequest: iso18626.Request{ @@ -472,7 +324,7 @@ func TestGeneratePdfPullSlip_ServiceInfoEmptyServiceLevel(t *testing.T) { }, }, } - pdfBytes, err := svc.GeneratePdfPullSlip(pr, nil, nil) + pdfBytes, err := svc.GeneratePdfPullSlip(appCtx, pr, nil, nil) assert.NoError(t, err) assert.NotEmpty(t, pdfBytes) } diff --git a/broker/pullslip/service/pull_slip_template.html b/broker/pullslip/service/pull_slip_template.html deleted file mode 100644 index b6e8ff9b8..000000000 --- a/broker/pullslip/service/pull_slip_template.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - Pull slip - - - -
-
-
Borrower & More
- Barcode - {{.ReqId}} -
- -
-
- Pickup Location - {{.PickupLocation}} -
-
- -
- Service Type: {{.ServiceType}}
- Service Level: {{.ServiceLevel}}
- System Identifier: {{.SystemIdentifier}}
- Title: {{.Title}}
- Author: {{.Author}}
- Publisher: {{.Publisher}}
- Volume(s): {{.Volume}}
- Issue: {{.Issue}}
- Pages: {{.Pages}}
-
- -
- DO NOT REMOVE THIS SLIP
- This material is on loan from a Trove Partner -
-
-
- Staff Notes: -
-                {{.StaffNotes}}
-            
-

- Call Number: - {{.CallNumber}} -
-
-
-
- Due Date: - {{.DueDate}} -

- Loan Conditions: -
-                {{.LoanConditions}}
-            
-
-
- -
-
- Return Address - {{.ReturnAddress}} -
-
-
- - \ No newline at end of file diff --git a/broker/scheduler/service/email_sender.go b/broker/scheduler/service/email_sender.go index 8a7e109b8..b28a23063 100644 --- a/broker/scheduler/service/email_sender.go +++ b/broker/scheduler/service/email_sender.go @@ -3,7 +3,6 @@ package sched_service import ( "errors" "fmt" - "strings" "github.com/indexdata/crosslink/broker/common" "github.com/indexdata/crosslink/broker/email" @@ -134,15 +133,11 @@ func (s *EmailSenderService) generateAndEmailPullslip(ctx common.ExtendedContext pdfAttachment = &email.PdfAttach{Filename: "pull-slips.pdf", Data: pdfBytes} } - placeholders := map[string]string{ - "fullCount": fmt.Sprintf("%d", fullCount), - "actualCount": fmt.Sprintf("%d", len(prs)), - "batchQuery": event.EventData.BatchActionData.Selector, - } + placeholders := email.GetBatchEmailData(fullCount, len(prs), event.EventData.BatchActionData.Selector) messageData := email.EmailData{ To: emailData.To, - Subject: renderEmailTemplate(template.Subject.String, placeholders), - Body: renderEmailTemplate(template.Body, placeholders), + Subject: email.RenderBatchEmailTemplate(template.Subject.String, placeholders), + Body: email.RenderBatchEmailTemplate(template.Body, placeholders), IsHTML: template.ContentType == string(proapi.Html), IncludePdf: emailData.IncludePdf, } @@ -159,13 +154,6 @@ func (s *EmailSenderService) generateAndEmailPullslip(ctx common.ExtendedContext return events.EventStatusSuccess, nil } -func renderEmailTemplate(value string, placeholders map[string]string) string { - for key, replacement := range placeholders { - value = strings.ReplaceAll(value, "{{"+key+"}}", replacement) - } - return value -} - // extractEmailData retrieves email pullslip parameters from the event's CustomData map. func extractEmailData(eventData events.EventData) (pullslipEmailData, error) { if eventData.CustomData == nil { diff --git a/broker/test/pullslip/api/api_handler_test.go b/broker/test/pullslip/api/api_handler_test.go index 86a8d0c6e..f550a929b 100644 --- a/broker/test/pullslip/api/api_handler_test.go +++ b/broker/test/pullslip/api/api_handler_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "slices" "strconv" "strings" "testing" @@ -115,6 +116,30 @@ func TestCreateSinglePullSlip(t *testing.T) { Items: []pr_db.PrItem{}, TerminalState: false, }) + + for _, tmpl := range prservice.GetStateModelTemplateDefaults() { + if slices.Contains(tmpl.Labels, "pullslip-pdf") { + _, inErr := prRepo.SaveTemplate(appCtx, pr_db.SaveTemplateParams{ + ID: "pullslip-pdf-1", + Owner: supSymbol, + Title: tmpl.Title, + Purpose: string(tmpl.Purpose), + Body: tmpl.Body, + ContentType: string(tmpl.ContentType), + Labels: tmpl.Labels, + Audience: pgtype.Text{ + String: string(*tmpl.Audience), + Valid: true, + }, + CreatedAt: pgtype.Timestamp{ + Time: time.Now(), + Valid: true, + }, + }) + assert.NoError(t, inErr, "failed to save template") + } + } + assert.NoError(t, err) // Create pull slip diff --git a/misc/state-models.yaml b/misc/state-models.yaml index 33fa06391..e4bc29884 100644 --- a/misc/state-models.yaml +++ b/misc/state-models.yaml @@ -4,6 +4,7 @@ stateModels: name: CrossLink State Model desc: "Requester/Supplier workflow for returnable loans and non-returnable copies over ISO18626." version: 3.3.0 + pulllslipPdfTemplateLabel: pullslip-pdf selector: serviceType: [Copy, Loan, CopyOrLoan] states: @@ -914,3 +915,131 @@ templateDefaults: Matching requests: {{actualCount}} (of {{fullCount}} total)

Please process the attached pull slips at your earliest convenience.

+ + - title: Pullslip PDF template + labels: + - pullslip-pdf + contentType: html + audience: staff + purpose: pullslip + body: | + + + + Pull slip + + + +
+
+
Borrower & More
+ Barcode + {{.ReqId}} +
+ +
+
+ Pickup Location + {{.PickupLocation}} +
+
+ +
+ Service Type: {{.ServiceType}}
+ Service Level: {{.ServiceLevel}}
+ System Identifier: {{.SystemIdentifier}}
+ Title: {{.Title}}
+ Author: {{.Author}}
+ Publisher: {{.Publisher}}
+ Volume(s): {{.Volume}}
+ Issue: {{.Issue}}
+ Pages: {{.Pages}}
+
+ +
+ DO NOT REMOVE THIS SLIP
+ This material is on loan from a Trove Partner +
+
+
+ Staff Notes: +
+                      {{.StaffNotes}}
+                  
+

+ Call Number: + {{.CallNumber}} +
+
+
+
+ Due Date: + {{.DueDate}} +

+ Loan Conditions: +
+                      {{.LoanConditions}}
+                  
+
+
+ +
+
+ Return Address + {{.ReturnAddress}} +
+
+
+ + From 94a471e0f07217d12530175a1f9d5ca2a3cb0c3c Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Tue, 25 Aug 2026 17:24:14 +0300 Subject: [PATCH 2/9] ILLDEV-466 Remove references of hardcoded template --- broker/Dockerfile.dockerignore | 3 -- broker/Makefile | 5 +-- broker/oapi/open-api.yaml | 2 +- .../patron_request/service/statemodel_test.go | 4 +- broker/pullslip/service/pdf.go | 6 +-- misc/state-models.yaml | 42 ++++++------------- 6 files changed, 21 insertions(+), 41 deletions(-) diff --git a/broker/Dockerfile.dockerignore b/broker/Dockerfile.dockerignore index 7b43bdcd2..c54502c7d 100644 --- a/broker/Dockerfile.dockerignore +++ b/broker/Dockerfile.dockerignore @@ -24,9 +24,6 @@ broker/**/*_test.go # Include statemodels !broker/patron_request/service/statemodels -# Include pull slip templates -!broker/pullslip/service/pull_slip_template.html - # Directory sources are generated inside the build. directory/api/directory.gen.go directory/db/db.go diff --git a/broker/Makefile b/broker/Makefile index 7e4f0482f..d4d7d68ad 100644 --- a/broker/Makefile +++ b/broker/Makefile @@ -22,7 +22,6 @@ GIT_COMMIT_DEPS = $(GIT_HEAD_FILE) $(wildcard $(GIT_HEAD_REF_FILE)) $(wildcard $ COVERAGE=coverage.out STATE_MODELS_JSON=patron_request/service/statemodels/state-models.json STATE_MODELS_YAML=../misc/state-models.yaml -PULLSLIP_TEMPLATE=pullslip/service/pull_slip_template.html # SQLC SQLC ?= $(GO) tool sqlc @@ -100,10 +99,10 @@ $(SQL_GEN_OUT): $(SQL_GEN_IN) $(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) $(PULLSLIP_TEMPLATE) +$(BINARY): $(COMMIT_ID) $(SQL_GEN_OUT) $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_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) $(PULLSLIP_TEMPLATE) +archive: $(COMMIT_ID) $(SQL_GEN_OUT) $(OAPI_GEN) $(PR_OAPI_GEN) $(PS_OAPI_GEN) $(SCHED_OAPI_GEN) $(BUILD_GOFILES) $(STATE_MODELS_JSON) $(GO) build -v -o archive ./cmd/archive check: generate diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index 7d885fda4..a4ddcf655 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -759,7 +759,7 @@ components: description: Version of the state model in SemVer selector: $ref: '#/components/schemas/StateModelSelector' - pulllslipPdfTemplateLabel: + pullslipPdfTemplateLabel: type: string description: Template label used to resolve the PDF pullslip template for this state model. states: diff --git a/broker/patron_request/service/statemodel_test.go b/broker/patron_request/service/statemodel_test.go index d94e5d03c..a3b6f66f6 100644 --- a/broker/patron_request/service/statemodel_test.go +++ b/broker/patron_request/service/statemodel_test.go @@ -225,8 +225,8 @@ func TestReturnablesPullslipPdfTemplateLabel(t *testing.T) { return } - if assert.NotNil(t, model.PulllslipPdfTemplateLabel) { - assert.Equal(t, "pullslip-pdf", *model.PulllslipPdfTemplateLabel) + if assert.NotNil(t, model.PullslipPdfTemplateLabel) { + assert.Equal(t, "pullslip-pdf", *model.PullslipPdfTemplateLabel) } } diff --git a/broker/pullslip/service/pdf.go b/broker/pullslip/service/pdf.go index f6dc7e570..335c2f77e 100644 --- a/broker/pullslip/service/pdf.go +++ b/broker/pullslip/service/pdf.go @@ -97,8 +97,8 @@ func (p *PdfServiceImpl) getTemplateForPatronRequest(ctx common.ExtendedContext, if err != nil { return "", err } - if stateModel.PulllslipPdfTemplateLabel == nil || *stateModel.PulllslipPdfTemplateLabel == "" { - return "", errors.New("pulllslipPdfTemplateLabel field is required") + if stateModel.PullslipPdfTemplateLabel == nil || *stateModel.PullslipPdfTemplateLabel == "" { + return "", errors.New("pullslipPdfTemplateLabel field is required") } owner := pr.RequesterSymbol if pr.Side == prservice.SideLending { @@ -107,7 +107,7 @@ func (p *PdfServiceImpl) getTemplateForPatronRequest(ctx common.ExtendedContext, pdfTemplate, err := p.prRepo.GetTemplateByPurposeAudienceLabelAndOwner(ctx, pr_db.GetTemplateByPurposeAudienceLabelAndOwnerParams{ Owner: owner.String, Purpose: string(proapi.Pullslip), - Label: *stateModel.PulllslipPdfTemplateLabel, + Label: *stateModel.PullslipPdfTemplateLabel, Audience: string(proapi.ModelActionParamsSendToStaff), }) if err != nil { diff --git a/misc/state-models.yaml b/misc/state-models.yaml index e4bc29884..91c2a9a38 100644 --- a/misc/state-models.yaml +++ b/misc/state-models.yaml @@ -3,8 +3,8 @@ stateModels: type: StateModel name: CrossLink State Model desc: "Requester/Supplier workflow for returnable loans and non-returnable copies over ISO18626." - version: 3.3.0 - pulllslipPdfTemplateLabel: pullslip-pdf + version: 3.4.0 + pullslipPdfTemplateLabel: pullslip-pdf selector: serviceType: [Copy, Loan, CopyOrLoan] states: @@ -80,16 +80,16 @@ stateModels: - name: METADATA_UPDATED display: Metadata Updated - desc: Request preparation has completed and pre-send checks can run + desc: Request metadata update has completed or been skipped side: REQUESTER - primaryAction: check-duplicate + primaryAction: send-request closingAction: close-request actions: - - name: check-duplicate - desc: Check for a recent matching patron request + - name: send-request + desc: Send ISO18626 request to the supplier or broker transitions: - success: READY_TO_SEND - review: DUPLICATE + success: SENT + duplicate: DUPLICATE trigger: auto - name: close-request desc: Close the request locally @@ -100,33 +100,16 @@ stateModels: display: Needs review desc: Request is valid but needs staff review before sending side: REQUESTER - primaryAction: check-duplicate + primaryAction: send-request closingAction: close-request needsAttention: true editable: true - actions: - - name: check-duplicate - desc: Check for a recent matching patron request before sending - transitions: - success: READY_TO_SEND - review: DUPLICATE - - name: close-request - desc: Close the request locally - transitions: - success: MANUALLY_CLOSED - - - name: READY_TO_SEND - display: Ready to send - desc: Duplicate check has completed and the request is ready to send - side: REQUESTER - primaryAction: send-request - closingAction: close-request actions: - name: send-request desc: Send ISO18626 request to the supplier or broker transitions: success: SENT - trigger: auto + duplicate: DUPLICATE - name: close-request desc: Close the request locally transitions: @@ -134,7 +117,7 @@ stateModels: - name: DUPLICATE display: Duplicate - desc: A recent matching patron request was found + desc: A duplicate request was reported by the supplier or broker side: REQUESTER primaryAction: send-request needsAttention: true @@ -142,9 +125,10 @@ stateModels: closingAction: close-request actions: - name: send-request - desc: Send the request despite the duplicate warning + desc: Retry sending the request to the supplier or broker transitions: success: SENT + duplicate: DUPLICATE - name: close-request desc: Close the duplicate request transitions: From 2fb367245aa50919934806056a67cae2dadd5134 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Wed, 26 Aug 2026 09:20:22 +0300 Subject: [PATCH 3/9] ILLDEV-466 Use default template if not specified --- broker/email/email.go | 33 +++++++++++++++++------- broker/email/email_test.go | 22 +++++++++++++--- broker/oapi/open-api.yaml | 13 ++++++++-- broker/patron_request/service/action.go | 26 ++++++++++--------- broker/pullslip/service/pdf.go | 19 +++++++++++++- broker/scheduler/service/email_sender.go | 4 +-- misc/state-models.yaml | 4 +-- 7 files changed, 88 insertions(+), 33 deletions(-) diff --git a/broker/email/email.go b/broker/email/email.go index cc8ca0c4b..d6e50d47d 100644 --- a/broker/email/email.go +++ b/broker/email/email.go @@ -11,6 +11,7 @@ import ( "mime/quotedprintable" "net/smtp" "net/textproto" + "reflect" "strings" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" @@ -187,6 +188,7 @@ func joinAddresses(addrs []string) string { return result } +// Only string fields are allowed type PullSlipData struct { BorrowerName string ReqId string @@ -211,6 +213,13 @@ type PullSlipData struct { PatronId string } +// Only string fields are allowed +type BatchEmailData struct { + FullCount string + ActualCount string + BatchQuery string +} + func GetPullSlipData(pr pr_db.PatronRequest, notes []pr_db.Notification, conditions []pr_db.Notification, barcodeData string) PullSlipData { data := PullSlipData{ ReqId: pr.RequesterReqID.String, @@ -283,7 +292,7 @@ func GetPullSlipData(pr pr_db.PatronRequest, notes []pr_db.Notification, conditi return data } -func RenderPullSlipHTMLWithTemplate(data PullSlipData, templateBody string) (string, error) { +func RenderHtmlTemplate(data any, templateBody string) (string, error) { tmpl, err := template.New("pull-slip").Parse(templateBody) if err != nil { return "", err @@ -372,17 +381,21 @@ func formatPhysicalAddress(a *iso18626.PhysicalAddress) string { return strings.Join(parts, ", ") } -func GetBatchEmailData(fullCount int64, actualCount int, batchQuery string) map[string]string { - return map[string]string{ - "fullCount": fmt.Sprintf("%d", fullCount), - "actualCount": fmt.Sprintf("%d", actualCount), - "batchQuery": batchQuery, +func GetBatchEmailData(fullCount int64, actualCount int, batchQuery string) BatchEmailData { + return BatchEmailData{ + FullCount: fmt.Sprintf("%d", fullCount), + ActualCount: fmt.Sprintf("%d", actualCount), + BatchQuery: batchQuery, } } -func RenderBatchEmailTemplate(value string, placeholders map[string]string) string { - for key, replacement := range placeholders { - value = strings.ReplaceAll(value, "{{"+key+"}}", replacement) +func RenderTextTemplate(data any, template string) string { + v := reflect.ValueOf(data) + t := v.Type() + for i := 0; i < t.NumField(); i++ { + key := t.Field(i).Name + replacement := v.Field(i).String() + template = strings.ReplaceAll(template, "{{."+key+"}}", replacement) } - return value + return template } diff --git a/broker/email/email_test.go b/broker/email/email_test.go index 14d2fde52..b5e9ca233 100644 --- a/broker/email/email_test.go +++ b/broker/email/email_test.go @@ -99,7 +99,7 @@ func TestBuildRawMessage_WithoutAttachment(t *testing.T) { func TestRenderPullSlipHTML(t *testing.T) { template := "
\n Service Type: {{.ServiceType}}
\n Service Level: {{.ServiceLevel}}
\n System Identifier: {{.SystemIdentifier}}
\n Title: {{.Title}}
\n Author: {{.Author}}
\n Publisher: {{.Publisher}}
\n Volume(s): {{.Volume}}
\n Issue: {{.Issue}}
\n Pages: {{.Pages}}
\n
" - html, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ + html, err := RenderHtmlTemplate(PullSlipData{ ServiceType: "Loan", Title: "Big Shark", Author: "John Doe", @@ -115,19 +115,19 @@ func TestRenderPullSlipHTML(t *testing.T) { } func TestRenderPullSlipHTML_UsesProvidedTemplate(t *testing.T) { - html, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") + html, err := RenderHtmlTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") assert.NoError(t, err) assert.Equal(t, "
REQ-1
", html) } func TestRenderPullSlipHTML_InvalidTemplate(t *testing.T) { - _, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") + _, err := RenderHtmlTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") assert.Error(t, err) } func TestRenderPullSlipHTML_ExecuteError(t *testing.T) { - _, err := RenderPullSlipHTMLWithTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") + _, err := RenderHtmlTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") // Execute on a struct with map-access fails assert.Error(t, err) } @@ -408,3 +408,17 @@ func TestGetPickupLocation_AddressWithNoUsableFields(t *testing.T) { } assert.Equal(t, DEFAULT_FOR_NO_VALUE, getPickupLocation(pr)) } + +func TestRenderTextTemplate(t *testing.T) { + template := "This is query '{{.BatchQuery}}'." + data := GetBatchEmailData(1, 1, "select 1 from dual") + assert.Equal(t, "This is query 'select 1 from dual'.", RenderTextTemplate(data, template)) + + template = "

This is request {{.ReqId}}.

" + prData := GetPullSlipData(pr_db.PatronRequest{RequesterReqID: pgtype.Text{String: "REQ-1", Valid: true}}, nil, nil, "") + assert.Equal(t, "

This is request REQ-1.

", RenderTextTemplate(prData, template)) + + result, err := RenderHtmlTemplate(prData, template) + assert.NoError(t, err) + assert.Equal(t, "

This is request REQ-1.

", result) +} diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index a4ddcf655..ece5844ef 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -1389,10 +1389,19 @@ components: $ref: '#/components/schemas/TemplatePurpose' subject: type: string - description: Subject line template, supports {{x}} placeholders. Not used for pullslip templates. + description: Subject line template, supports {{.X}} placeholders. Not used for pullslip templates. Supports same placeholders as template body. body: type: string - description: Body of the email or pull slip template. Supports {{x}} placeholders. + description: >- + Body of the email or pull slip template. Supports {{.X}} placeholders. + Supported placeholders for patron request templates include + {{.BorrowerName}}, {{.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}}. contentType: $ref: '#/components/schemas/TemplateContentType' labels: diff --git a/broker/patron_request/service/action.go b/broker/patron_request/service/action.go index 6382004db..f731b2e93 100644 --- a/broker/patron_request/service/action.go +++ b/broker/patron_request/service/action.go @@ -1752,26 +1752,28 @@ func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedConte if err != nil { return err } - body := template.Body + var body string + notes, _, err := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindNote)}) + if err != nil { + return err + } + conditions, _, err := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindCondition)}) + if err != nil { + return err + } + data := email.GetPullSlipData(pr, notes, conditions, email.DEFAULT_FOR_NO_VALUE) if template.ContentType == string(proapi.Html) { - notes, _, ifErr := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindNote)}) - if ifErr != nil { - return ifErr - } - conditions, _, ifErr := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindCondition)}) - if ifErr != nil { - return ifErr - } - data := email.GetPullSlipData(pr, notes, conditions, email.DEFAULT_FOR_NO_VALUE) - html, ifErr := email.RenderPullSlipHTMLWithTemplate(data, template.Body) + html, ifErr := email.RenderHtmlTemplate(data, template.Body) if ifErr != nil { return ifErr } body = html + } else { + body = email.RenderTextTemplate(data, template.Body) } emailData := email.EmailData{ To: recipients, - Subject: template.Subject.String, + Subject: email.RenderTextTemplate(data, template.Subject.String), Body: body, IsHTML: template.ContentType == string(proapi.Html), IncludePdf: false, diff --git a/broker/pullslip/service/pdf.go b/broker/pullslip/service/pdf.go index 335c2f77e..b2d6bc712 100644 --- a/broker/pullslip/service/pdf.go +++ b/broker/pullslip/service/pdf.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "errors" "image/png" + "slices" "github.com/boombuler/barcode" "github.com/boombuler/barcode/code128" @@ -16,6 +17,7 @@ import ( 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" + "github.com/jackc/pgx/v5" ) type PdfService interface { @@ -79,7 +81,7 @@ func (p *PdfServiceImpl) GeneratePdfPullSlip(ctx common.ExtendedContext, pr pr_d doc := document.NewDocument(document.PageSizeA4) data := email.GetPullSlipData(pr, notes, conditions, barcodeData) - html, err := email.RenderPullSlipHTMLWithTemplate(data, templateBody) + html, err := email.RenderHtmlTemplate(data, templateBody) if err != nil { return nil, err } @@ -111,11 +113,17 @@ func (p *PdfServiceImpl) getTemplateForPatronRequest(ctx common.ExtendedContext, Audience: string(proapi.ModelActionParamsSendToStaff), }) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return getDefaultTemplate(*stateModel.PullslipPdfTemplateLabel) + } return "", err } if pdfTemplate.Body == "" { return "", errors.New("invalid pullslip pdf template, body field is required") } + if pdfTemplate.ContentType != string(proapi.Html) { + return "", errors.New("invalid pullslip pdf template, it must be of type HTML") + } return pdfTemplate.Body, nil } @@ -154,3 +162,12 @@ func getBarcodeBase64(data string) (string, error) { } return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } + +func getDefaultTemplate(label string) (string, error) { + for _, t := range prservice.GetStateModelTemplateDefaults() { + if slices.Contains(t.Labels, label) { + return t.Body, nil + } + } + return "", errors.New("no default template found for label: " + label) +} diff --git a/broker/scheduler/service/email_sender.go b/broker/scheduler/service/email_sender.go index b28a23063..5847b3d09 100644 --- a/broker/scheduler/service/email_sender.go +++ b/broker/scheduler/service/email_sender.go @@ -136,8 +136,8 @@ func (s *EmailSenderService) generateAndEmailPullslip(ctx common.ExtendedContext placeholders := email.GetBatchEmailData(fullCount, len(prs), event.EventData.BatchActionData.Selector) messageData := email.EmailData{ To: emailData.To, - Subject: email.RenderBatchEmailTemplate(template.Subject.String, placeholders), - Body: email.RenderBatchEmailTemplate(template.Body, placeholders), + Subject: email.RenderTextTemplate(placeholders, template.Subject.String), + Body: email.RenderTextTemplate(placeholders, template.Body), IsHTML: template.ContentType == string(proapi.Html), IncludePdf: emailData.IncludePdf, } diff --git a/misc/state-models.yaml b/misc/state-models.yaml index 91c2a9a38..760aafc79 100644 --- a/misc/state-models.yaml +++ b/misc/state-models.yaml @@ -895,8 +895,8 @@ templateDefaults: body: |

This is an automated pull slip summary.

- Query: {{batchQuery}}
- Matching requests: {{actualCount}} (of {{fullCount}} total) + Query: {{.BatchQuery}}
+ Matching requests: {{.ActualCount}} (of {{.FullCount}} total)

Please process the attached pull slips at your earliest convenience.

From ab9c167781afca9522c44c2957fa539634102bf4 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Wed, 26 Aug 2026 10:02:16 +0300 Subject: [PATCH 4/9] ILLDEV-466 Fix tests --- broker/pullslip/api/api_handler_test.go | 4 +- broker/pullslip/service/pdf_test.go | 4 +- broker/scheduler/service/email_sender_test.go | 4 +- misc/state-models.yaml | 38 +++++++++++++------ 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/broker/pullslip/api/api_handler_test.go b/broker/pullslip/api/api_handler_test.go index 4c809ecef..b7c34f43c 100644 --- a/broker/pullslip/api/api_handler_test.go +++ b/broker/pullslip/api/api_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/indexdata/cql-go/pgcql" "github.com/indexdata/crosslink/broker/common" 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" ps_db "github.com/indexdata/crosslink/broker/pullslip/db" psoapi "github.com/indexdata/crosslink/broker/pullslip/oapi" @@ -58,7 +59,8 @@ func (m *MockPrRepo) GetTemplateByPurposeAudienceLabelAndOwner(_ common.Extended for _, t := range prservice.GetStateModelTemplateDefaults() { if slices.Contains(t.Labels, params.Label) { return pr_db.Template{ - Body: t.Body, + Body: t.Body, + ContentType: string(proapi.Html), }, nil } } diff --git a/broker/pullslip/service/pdf_test.go b/broker/pullslip/service/pdf_test.go index 2eb1bd19b..d17125a42 100644 --- a/broker/pullslip/service/pdf_test.go +++ b/broker/pullslip/service/pdf_test.go @@ -12,6 +12,7 @@ import ( "github.com/indexdata/crosslink/broker/common" 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" "github.com/indexdata/crosslink/iso18626" "github.com/indexdata/go-utils/utils" @@ -259,7 +260,8 @@ func (m *mockPrRepo) GetTemplateByPurposeAudienceLabelAndOwner(_ common.Extended for _, t := range prservice.GetStateModelTemplateDefaults() { if slices.Contains(t.Labels, params.Label) { return pr_db.Template{ - Body: t.Body, + Body: t.Body, + ContentType: string(proapi.Html), }, nil } } diff --git a/broker/scheduler/service/email_sender_test.go b/broker/scheduler/service/email_sender_test.go index 6bad91527..126a63400 100644 --- a/broker/scheduler/service/email_sender_test.go +++ b/broker/scheduler/service/email_sender_test.go @@ -378,8 +378,8 @@ func TestGenerateAndEmailPullslip_PerformsPlaceholderSubstitution(t *testing.T) fullCount: 5, template: pr_db.Template{ ID: "template-id", - Subject: pgtype.Text{String: "Selected {{fullCount}}", Valid: true}, - Body: "Attached {{actualCount}} of {{fullCount}} from {{batchQuery}}", + Subject: pgtype.Text{String: "Selected {{.FullCount}}", Valid: true}, + Body: "Attached {{.ActualCount}} of {{.FullCount}} from {{.BatchQuery}}", ContentType: "text", }, } diff --git a/misc/state-models.yaml b/misc/state-models.yaml index 760aafc79..f0ee3710f 100644 --- a/misc/state-models.yaml +++ b/misc/state-models.yaml @@ -80,16 +80,16 @@ stateModels: - name: METADATA_UPDATED display: Metadata Updated - desc: Request metadata update has completed or been skipped + desc: Request preparation has completed and pre-send checks can run side: REQUESTER - primaryAction: send-request + primaryAction: check-duplicate closingAction: close-request actions: - - name: send-request - desc: Send ISO18626 request to the supplier or broker + - name: check-duplicate + desc: Check for a recent matching patron request transitions: - success: SENT - duplicate: DUPLICATE + success: READY_TO_SEND + review: DUPLICATE trigger: auto - name: close-request desc: Close the request locally @@ -100,16 +100,33 @@ stateModels: display: Needs review desc: Request is valid but needs staff review before sending side: REQUESTER - primaryAction: send-request + primaryAction: check-duplicate closingAction: close-request needsAttention: true editable: true + actions: + - name: check-duplicate + desc: Check for a recent matching patron request before sending + transitions: + success: READY_TO_SEND + review: DUPLICATE + - name: close-request + desc: Close the request locally + transitions: + success: MANUALLY_CLOSED + + - name: READY_TO_SEND + display: Ready to send + desc: Duplicate check has completed and the request is ready to send + side: REQUESTER + primaryAction: send-request + closingAction: close-request actions: - name: send-request desc: Send ISO18626 request to the supplier or broker transitions: success: SENT - duplicate: DUPLICATE + trigger: auto - name: close-request desc: Close the request locally transitions: @@ -117,7 +134,7 @@ stateModels: - name: DUPLICATE display: Duplicate - desc: A duplicate request was reported by the supplier or broker + desc: A recent matching patron request was found side: REQUESTER primaryAction: send-request needsAttention: true @@ -125,10 +142,9 @@ stateModels: closingAction: close-request actions: - name: send-request - desc: Retry sending the request to the supplier or broker + desc: Send the request despite the duplicate warning transitions: success: SENT - duplicate: DUPLICATE - name: close-request desc: Close the duplicate request transitions: From 69008ef7b99d7944e02e781081ae3692bc28dcf0 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Wed, 26 Aug 2026 10:22:20 +0300 Subject: [PATCH 5/9] ILLDEV-466 Fix open api documentation --- broker/oapi/open-api.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index ece5844ef..277b31e49 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -1454,10 +1454,10 @@ components: $ref: '#/components/schemas/TemplatePurpose' subject: type: string - description: Subject line template, supports {{x}} placeholders. Not used for pullslip templates. + description: Subject line template, supports {{.X}} placeholders. Not used for pullslip templates. For full list of supported placeholders, see the Template object. body: type: string - description: Body of the email or pull slip template. Supports {{x}} placeholders. + description: Body of the email or pull slip template. Supports {{.X}} placeholders. For full list of supported placeholders, see the Template object. contentType: $ref: '#/components/schemas/TemplateContentType' labels: @@ -1484,10 +1484,10 @@ components: description: Human-readable title for the template subject: type: string - description: Subject line template supporting {{x}} placeholders. Not used for pull slip templates. Omit to clear. + description: 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. body: type: string - description: Body of the email or pull slip template. Supports {{x}} placeholders. + description: Body of the email or pull slip template. Supports {{.X}} placeholders. For full list of supported placeholders, see the Template object. contentType: $ref: '#/components/schemas/TemplateContentType' labels: From 20616d0201de876c9229f032b013c7362b2e29a9 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Thu, 27 Aug 2026 11:18:17 +0300 Subject: [PATCH 6/9] ILLDEV-466 Use same render method --- broker/email/email.go | 14 +------- broker/email/email_test.go | 16 ++++----- broker/patron_request/service/action.go | 18 +++++----- broker/patron_request/service/action_test.go | 32 +++++++++++++++++ broker/pullslip/service/pdf.go | 2 +- broker/scheduler/service/email_sender.go | 12 +++++-- broker/scheduler/service/email_sender_test.go | 34 +++++++++++++++++++ 7 files changed, 94 insertions(+), 34 deletions(-) diff --git a/broker/email/email.go b/broker/email/email.go index d6e50d47d..7e6978bc5 100644 --- a/broker/email/email.go +++ b/broker/email/email.go @@ -11,7 +11,6 @@ import ( "mime/quotedprintable" "net/smtp" "net/textproto" - "reflect" "strings" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" @@ -292,7 +291,7 @@ func GetPullSlipData(pr pr_db.PatronRequest, notes []pr_db.Notification, conditi return data } -func RenderHtmlTemplate(data any, templateBody string) (string, error) { +func RenderTemplate(data any, templateBody string) (string, error) { tmpl, err := template.New("pull-slip").Parse(templateBody) if err != nil { return "", err @@ -388,14 +387,3 @@ func GetBatchEmailData(fullCount int64, actualCount int, batchQuery string) Batc BatchQuery: batchQuery, } } - -func RenderTextTemplate(data any, template string) string { - v := reflect.ValueOf(data) - t := v.Type() - for i := 0; i < t.NumField(); i++ { - key := t.Field(i).Name - replacement := v.Field(i).String() - template = strings.ReplaceAll(template, "{{."+key+"}}", replacement) - } - return template -} diff --git a/broker/email/email_test.go b/broker/email/email_test.go index b5e9ca233..f8b1caeac 100644 --- a/broker/email/email_test.go +++ b/broker/email/email_test.go @@ -99,7 +99,7 @@ func TestBuildRawMessage_WithoutAttachment(t *testing.T) { func TestRenderPullSlipHTML(t *testing.T) { template := "
\n Service Type: {{.ServiceType}}
\n Service Level: {{.ServiceLevel}}
\n System Identifier: {{.SystemIdentifier}}
\n Title: {{.Title}}
\n Author: {{.Author}}
\n Publisher: {{.Publisher}}
\n Volume(s): {{.Volume}}
\n Issue: {{.Issue}}
\n Pages: {{.Pages}}
\n
" - html, err := RenderHtmlTemplate(PullSlipData{ + html, err := RenderTemplate(PullSlipData{ ServiceType: "Loan", Title: "Big Shark", Author: "John Doe", @@ -115,19 +115,19 @@ func TestRenderPullSlipHTML(t *testing.T) { } func TestRenderPullSlipHTML_UsesProvidedTemplate(t *testing.T) { - html, err := RenderHtmlTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") + html, err := RenderTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") assert.NoError(t, err) assert.Equal(t, "
REQ-1
", html) } func TestRenderPullSlipHTML_InvalidTemplate(t *testing.T) { - _, err := RenderHtmlTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") + _, err := RenderTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") assert.Error(t, err) } func TestRenderPullSlipHTML_ExecuteError(t *testing.T) { - _, err := RenderHtmlTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") + _, err := RenderTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") // Execute on a struct with map-access fails assert.Error(t, err) } @@ -412,13 +412,13 @@ func TestGetPickupLocation_AddressWithNoUsableFields(t *testing.T) { func TestRenderTextTemplate(t *testing.T) { template := "This is query '{{.BatchQuery}}'." data := GetBatchEmailData(1, 1, "select 1 from dual") - assert.Equal(t, "This is query 'select 1 from dual'.", RenderTextTemplate(data, template)) + result, err := RenderTemplate(data, template) + assert.NoError(t, err) + assert.Equal(t, "This is query 'select 1 from dual'.", result) template = "

This is request {{.ReqId}}.

" prData := GetPullSlipData(pr_db.PatronRequest{RequesterReqID: pgtype.Text{String: "REQ-1", Valid: true}}, nil, nil, "") - assert.Equal(t, "

This is request REQ-1.

", RenderTextTemplate(prData, template)) - - result, err := RenderHtmlTemplate(prData, template) + result, err = RenderTemplate(prData, template) assert.NoError(t, err) assert.Equal(t, "

This is request REQ-1.

", result) } diff --git a/broker/patron_request/service/action.go b/broker/patron_request/service/action.go index f731b2e93..c6119e884 100644 --- a/broker/patron_request/service/action.go +++ b/broker/patron_request/service/action.go @@ -1752,7 +1752,6 @@ func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedConte if err != nil { return err } - var body string notes, _, err := a.prRepo.GetNotificationsByPrId(ctx, pr_db.GetNotificationsByPrIdParams{Limit: 100, Offset: 0, PrID: pr.ID, Kind: string(pr_db.NotificationKindNote)}) if err != nil { return err @@ -1762,18 +1761,17 @@ func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedConte return err } data := email.GetPullSlipData(pr, notes, conditions, email.DEFAULT_FOR_NO_VALUE) - if template.ContentType == string(proapi.Html) { - html, ifErr := email.RenderHtmlTemplate(data, template.Body) - if ifErr != nil { - return ifErr - } - body = html - } else { - body = email.RenderTextTemplate(data, template.Body) + body, err := email.RenderTemplate(data, template.Body) + if err != nil { + return err + } + subject, err := email.RenderTemplate(data, template.Subject.String) + if err != nil { + return err } emailData := email.EmailData{ To: recipients, - Subject: email.RenderTextTemplate(data, template.Subject.String), + Subject: subject, Body: body, IsHTML: template.ContentType == string(proapi.Html), IncludePdf: false, diff --git a/broker/patron_request/service/action_test.go b/broker/patron_request/service/action_test.go index 992372d4c..d69520783 100644 --- a/broker/patron_request/service/action_test.go +++ b/broker/patron_request/service/action_test.go @@ -3851,6 +3851,38 @@ func TestCreateAndSendEmail(t *testing.T) { }, wantErrSubstr: "header injection", }, + { + name: "invalid template body", + from: "from@example.com", + recipients: recipients, + setupPrRepo: func(m *MockPrRepo) { + m.On("GetTemplateByPurposeAudienceLabelAndOwner", mock.Anything).Return(pr_db.Template{ + Body: "Hello patron {{.PatronEmai ", + Subject: pgtype.Text{String: "Your request", Valid: true}, + }, nil) + }, + setupEmail: func(m *EmailSenderMock) {}, + assertEmail: func(t *testing.T, m *EmailSenderMock) { + m.AssertNotCalled(t, "SendEmail", mock.Anything) + }, + wantErrSubstr: "template: pull-slip:1: unclosed action", + }, + { + name: "invalid template body", + from: "from@example.com", + recipients: recipients, + setupPrRepo: func(m *MockPrRepo) { + m.On("GetTemplateByPurposeAudienceLabelAndOwner", mock.Anything).Return(pr_db.Template{ + Body: "Hello patron", + Subject: pgtype.Text{String: "Your request {{.PatronEmai ", Valid: true}, + }, nil) + }, + setupEmail: func(m *EmailSenderMock) {}, + assertEmail: func(t *testing.T, m *EmailSenderMock) { + m.AssertNotCalled(t, "SendEmail", mock.Anything) + }, + wantErrSubstr: "template: pull-slip:1: unclosed action", + }, } for _, tc := range tests { diff --git a/broker/pullslip/service/pdf.go b/broker/pullslip/service/pdf.go index b2d6bc712..b6c721d2a 100644 --- a/broker/pullslip/service/pdf.go +++ b/broker/pullslip/service/pdf.go @@ -81,7 +81,7 @@ func (p *PdfServiceImpl) GeneratePdfPullSlip(ctx common.ExtendedContext, pr pr_d doc := document.NewDocument(document.PageSizeA4) data := email.GetPullSlipData(pr, notes, conditions, barcodeData) - html, err := email.RenderHtmlTemplate(data, templateBody) + html, err := email.RenderTemplate(data, templateBody) if err != nil { return nil, err } diff --git a/broker/scheduler/service/email_sender.go b/broker/scheduler/service/email_sender.go index 5847b3d09..3559198cb 100644 --- a/broker/scheduler/service/email_sender.go +++ b/broker/scheduler/service/email_sender.go @@ -134,10 +134,18 @@ func (s *EmailSenderService) generateAndEmailPullslip(ctx common.ExtendedContext } placeholders := email.GetBatchEmailData(fullCount, len(prs), event.EventData.BatchActionData.Selector) + body, err := email.RenderTemplate(placeholders, template.Body) + if err != nil { + return events.NewErrorResult("failed to render email body", err.Error()) + } + subject, err := email.RenderTemplate(placeholders, template.Subject.String) + if err != nil { + return events.NewErrorResult("failed to render email subject", err.Error()) + } messageData := email.EmailData{ To: emailData.To, - Subject: email.RenderTextTemplate(placeholders, template.Subject.String), - Body: email.RenderTextTemplate(placeholders, template.Body), + Subject: subject, + Body: body, IsHTML: template.ContentType == string(proapi.Html), IncludePdf: emailData.IncludePdf, } diff --git a/broker/scheduler/service/email_sender_test.go b/broker/scheduler/service/email_sender_test.go index 126a63400..c35f50cfd 100644 --- a/broker/scheduler/service/email_sender_test.go +++ b/broker/scheduler/service/email_sender_test.go @@ -323,6 +323,40 @@ func TestGenerateAndEmailPullslip_TemplateEmptySubject(t *testing.T) { assert.NotNil(t, result) } +func TestGenerateAndEmailPullslip_TemplateInvalidBody(t *testing.T) { + prRepo := &mockEmailPrRepo{template: pr_db.Template{ + ID: "template-id", + Subject: pgtype.Text{ + Valid: true, + String: "Subject", + }, + Body: "Body {{.Invalid text", + ContentType: "text", + }} + svc := newEmailSvc(prRepo, &mockEmailService{}, nil) + status, result := svc.generateAndEmailPullslip(testCtx, validEmailEvent()) + assert.Equal(t, events.EventStatusError, status) + assert.NotNil(t, result) + assert.Equal(t, "failed to render email body", result.EventError.Message) +} + +func TestGenerateAndEmailPullslip_TemplateInvalidSubject(t *testing.T) { + prRepo := &mockEmailPrRepo{template: pr_db.Template{ + ID: "template-id", + Subject: pgtype.Text{ + Valid: true, + String: "Subject {{.Invalid text", + }, + Body: "Body", + ContentType: "text", + }} + svc := newEmailSvc(prRepo, &mockEmailService{}, nil) + status, result := svc.generateAndEmailPullslip(testCtx, validEmailEvent()) + assert.Equal(t, events.EventStatusError, status) + assert.NotNil(t, result) + assert.Equal(t, "failed to render email subject", result.EventError.Message) +} + func TestGenerateAndEmailPullslip_TemplateEmptyBody(t *testing.T) { prRepo := &mockEmailPrRepo{template: pr_db.Template{ ID: "template-id", From e793f41049a6c5d60858dd4bb4b7503a468f5853 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Thu, 27 Aug 2026 11:41:56 +0300 Subject: [PATCH 7/9] ILLDEV-466 Update existing templates with new placeholders --- .../059_normalize_templates.down.sql | 26 +++++++++++++++++++ .../migrations/059_normalize_templates.up.sql | 26 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 broker/migrations/059_normalize_templates.down.sql create mode 100644 broker/migrations/059_normalize_templates.up.sql diff --git a/broker/migrations/059_normalize_templates.down.sql b/broker/migrations/059_normalize_templates.down.sql new file mode 100644 index 000000000..5288ff78a --- /dev/null +++ b/broker/migrations/059_normalize_templates.down.sql @@ -0,0 +1,26 @@ +-- Revert placeholders in template.subject and template.body +-- Old: {{.BatchQuery}}, {{.ActualCount}}, {{.FullCount}} +-- New: {{batchQuery}}, {{actualCount}}, {{fullCount}} +UPDATE template +SET + subject = REPLACE( + REPLACE( + REPLACE(subject, '{{.BatchQuery}}', '{{batchQuery}}'), + '{{.ActualCount}}', '{{actualCount}}' + ), + '{{.FullCount}}', '{{fullCount}}' + ), + body = REPLACE( + REPLACE( + REPLACE(body, '{{.BatchQuery}}', '{{batchQuery}}'), + '{{.ActualCount}}', '{{actualCount}}' + ), + '{{.FullCount}}', '{{fullCount}}' + ) +WHERE + subject LIKE '%{{.BatchQuery}}%' + OR subject LIKE '%{{.ActualCount}}%' + OR subject LIKE '%{{.FullCount}}%' + OR body LIKE '%{{.BatchQuery}}%' + OR body LIKE '%{{.ActualCount}}%' + OR body LIKE '%{{.FullCount}}%'; \ No newline at end of file diff --git a/broker/migrations/059_normalize_templates.up.sql b/broker/migrations/059_normalize_templates.up.sql new file mode 100644 index 000000000..71fd07287 --- /dev/null +++ b/broker/migrations/059_normalize_templates.up.sql @@ -0,0 +1,26 @@ +-- Replace placeholders in template.subject and template.body +-- Old: {{batchQuery}}, {{actualCount}}, {{fullCount}} +-- New: {{.BatchQuery}}, {{.ActualCount}}, {{.FullCount}} +UPDATE template +SET + subject = REPLACE( + REPLACE( + REPLACE(subject, '{{batchQuery}}', '{{.BatchQuery}}'), + '{{actualCount}}', '{{.ActualCount}}' + ), + '{{fullCount}}', '{{.FullCount}}' + ), + body = REPLACE( + REPLACE( + REPLACE(body, '{{batchQuery}}', '{{.BatchQuery}}'), + '{{actualCount}}', '{{.ActualCount}}' + ), + '{{fullCount}}', '{{.FullCount}}' + ) +WHERE + subject LIKE '%{{batchQuery}}%' + OR subject LIKE '%{{actualCount}}%' + OR subject LIKE '%{{fullCount}}%' + OR body LIKE '%{{batchQuery}}%' + OR body LIKE '%{{actualCount}}%' + OR body LIKE '%{{fullCount}}%'; \ No newline at end of file From 8da41d27ab2fcfdeeedef61ac87e006939e6a13c Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Fri, 28 Aug 2026 13:26:05 +0300 Subject: [PATCH 8/9] ILLDEV-493 Don't send email if no patron requests are found --- broker/email/email.go | 16 +++++++++++++-- broker/email/email_test.go | 26 +++++++++++------------- broker/oapi/open-api.yaml | 2 +- broker/patron_request/service/action.go | 9 ++++++-- broker/pullslip/service/pdf.go | 2 +- broker/scheduler/service/email_sender.go | 9 ++++++-- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/broker/email/email.go b/broker/email/email.go index 7e6978bc5..f29457d3c 100644 --- a/broker/email/email.go +++ b/broker/email/email.go @@ -12,6 +12,7 @@ import ( "net/smtp" "net/textproto" "strings" + text_template "text/template" pr_db "github.com/indexdata/crosslink/broker/patron_request/db" "github.com/indexdata/crosslink/iso18626" @@ -189,7 +190,6 @@ func joinAddresses(addrs []string) string { // Only string fields are allowed type PullSlipData struct { - BorrowerName string ReqId string PickupLocation string Title string @@ -291,7 +291,7 @@ func GetPullSlipData(pr pr_db.PatronRequest, notes []pr_db.Notification, conditi return data } -func RenderTemplate(data any, templateBody string) (string, error) { +func RenderHtmlTemplate(data any, templateBody string) (string, error) { tmpl, err := template.New("pull-slip").Parse(templateBody) if err != nil { return "", err @@ -303,6 +303,18 @@ func RenderTemplate(data any, templateBody string) (string, error) { return buf.String(), nil } +func RenderTextTemplate(data any, templateBody string) (string, error) { + tmpl, err := text_template.New("pull-slip").Parse(templateBody) + if err != nil { + return "", err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + func getStaffNotes(noteList []pr_db.Notification) string { noteStrings := []string{} for _, note := range noteList { diff --git a/broker/email/email_test.go b/broker/email/email_test.go index f8b1caeac..c285424ff 100644 --- a/broker/email/email_test.go +++ b/broker/email/email_test.go @@ -99,7 +99,7 @@ func TestBuildRawMessage_WithoutAttachment(t *testing.T) { func TestRenderPullSlipHTML(t *testing.T) { template := "
\n Service Type: {{.ServiceType}}
\n Service Level: {{.ServiceLevel}}
\n System Identifier: {{.SystemIdentifier}}
\n Title: {{.Title}}
\n Author: {{.Author}}
\n Publisher: {{.Publisher}}
\n Volume(s): {{.Volume}}
\n Issue: {{.Issue}}
\n Pages: {{.Pages}}
\n
" - html, err := RenderTemplate(PullSlipData{ + html, err := RenderHtmlTemplate(PullSlipData{ ServiceType: "Loan", Title: "Big Shark", Author: "John Doe", @@ -115,19 +115,19 @@ func TestRenderPullSlipHTML(t *testing.T) { } func TestRenderPullSlipHTML_UsesProvidedTemplate(t *testing.T) { - html, err := RenderTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") + html, err := RenderHtmlTemplate(PullSlipData{ReqId: "REQ-1"}, "
{{.ReqId}}
") assert.NoError(t, err) assert.Equal(t, "
REQ-1
", html) } func TestRenderPullSlipHTML_InvalidTemplate(t *testing.T) { - _, err := RenderTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") + _, err := RenderHtmlTemplate(PullSlipData{ReqId: "X"}, "{{.Unclosed") assert.Error(t, err) } func TestRenderPullSlipHTML_ExecuteError(t *testing.T) { - _, err := RenderTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") + _, err := RenderHtmlTemplate(PullSlipData{ReqId: "X"}, "{{index . \"nonexistent\"}}") // Execute on a struct with map-access fails assert.Error(t, err) } @@ -195,7 +195,6 @@ func TestGetPullSlipData_PopulatesAllAvailableFields(t *testing.T) { data := GetPullSlipData(pr, notes, conditions, "barcode-base64") assert.Equal(t, PullSlipData{ - BorrowerName: "", ReqId: "REQ-123", PickupLocation: "Pickup Desk, Riga, LV-1050, LV", Title: "Distributed Libraries", @@ -223,7 +222,6 @@ func TestGetPullSlipData_UsesDefaultsWhenOptionalFieldsAreMissing(t *testing.T) data := GetPullSlipData(pr_db.PatronRequest{}, nil, nil, DEFAULT_FOR_NO_VALUE) assert.Equal(t, PullSlipData{ - BorrowerName: "", ReqId: "", PickupLocation: DEFAULT_FOR_NO_VALUE, Title: DEFAULT_FOR_NO_VALUE, @@ -410,15 +408,15 @@ func TestGetPickupLocation_AddressWithNoUsableFields(t *testing.T) { } func TestRenderTextTemplate(t *testing.T) { - template := "This is query '{{.BatchQuery}}'." - data := GetBatchEmailData(1, 1, "select 1 from dual") - result, err := RenderTemplate(data, template) + template := "This is A&B query '{{.BatchQuery}}'." + data := GetBatchEmailData(1, 1, "select \"A&B\" from dual") + result, err := RenderTextTemplate(data, template) assert.NoError(t, err) - assert.Equal(t, "This is query 'select 1 from dual'.", result) + assert.Equal(t, "This is A&B query 'select \"A&B\" from dual'.", result) - template = "

This is request {{.ReqId}}.

" - prData := GetPullSlipData(pr_db.PatronRequest{RequesterReqID: pgtype.Text{String: "REQ-1", Valid: true}}, nil, nil, "") - result, err = RenderTemplate(prData, template) + template = "

This is A&B request {{.ReqId}}.

" + prData := GetPullSlipData(pr_db.PatronRequest{RequesterReqID: pgtype.Text{String: "REQ-1-A&B", Valid: true}}, nil, nil, "") + result, err = RenderHtmlTemplate(prData, template) assert.NoError(t, err) - assert.Equal(t, "

This is request REQ-1.

", result) + assert.Equal(t, "

This is A&B request REQ-1-A&B.

", result) } diff --git a/broker/oapi/open-api.yaml b/broker/oapi/open-api.yaml index 277b31e49..aaf5c9608 100644 --- a/broker/oapi/open-api.yaml +++ b/broker/oapi/open-api.yaml @@ -1395,7 +1395,7 @@ components: description: >- Body of the email or pull slip template. Supports {{.X}} placeholders. Supported placeholders for patron request templates include - {{.BorrowerName}}, {{.ReqId}}, {{.PickupLocation}}, {{.Title}}, + {{.ReqId}}, {{.PickupLocation}}, {{.Title}}, {{.Author}}, {{.DueDate}}, {{.ReturnAddress}}, {{.BarcodeBase64}}, {{.ServiceType}}, {{.ServiceLevel}}, {{.SystemIdentifier}}, {{.Publisher}}, {{.Volume}}, {{.Issue}}, {{.Pages}}, {{.StaffNotes}}, diff --git a/broker/patron_request/service/action.go b/broker/patron_request/service/action.go index c6119e884..4fb5c190d 100644 --- a/broker/patron_request/service/action.go +++ b/broker/patron_request/service/action.go @@ -1761,11 +1761,16 @@ func (a *PatronRequestActionService) createAndSendEmail(ctx common.ExtendedConte return err } data := email.GetPullSlipData(pr, notes, conditions, email.DEFAULT_FOR_NO_VALUE) - body, err := email.RenderTemplate(data, template.Body) + var body string + if template.ContentType == string(proapi.Html) { + body, err = email.RenderHtmlTemplate(data, template.Body) + } else { + body, err = email.RenderTextTemplate(data, template.Body) + } if err != nil { return err } - subject, err := email.RenderTemplate(data, template.Subject.String) + subject, err := email.RenderTextTemplate(data, template.Subject.String) if err != nil { return err } diff --git a/broker/pullslip/service/pdf.go b/broker/pullslip/service/pdf.go index b6c721d2a..b2d6bc712 100644 --- a/broker/pullslip/service/pdf.go +++ b/broker/pullslip/service/pdf.go @@ -81,7 +81,7 @@ func (p *PdfServiceImpl) GeneratePdfPullSlip(ctx common.ExtendedContext, pr pr_d doc := document.NewDocument(document.PageSizeA4) data := email.GetPullSlipData(pr, notes, conditions, barcodeData) - html, err := email.RenderTemplate(data, templateBody) + html, err := email.RenderHtmlTemplate(data, templateBody) if err != nil { return nil, err } diff --git a/broker/scheduler/service/email_sender.go b/broker/scheduler/service/email_sender.go index 3559198cb..02ed5cf5a 100644 --- a/broker/scheduler/service/email_sender.go +++ b/broker/scheduler/service/email_sender.go @@ -134,11 +134,16 @@ func (s *EmailSenderService) generateAndEmailPullslip(ctx common.ExtendedContext } placeholders := email.GetBatchEmailData(fullCount, len(prs), event.EventData.BatchActionData.Selector) - body, err := email.RenderTemplate(placeholders, template.Body) + var body string + if template.ContentType == string(proapi.Html) { + body, err = email.RenderHtmlTemplate(placeholders, template.Body) + } else { + body, err = email.RenderTextTemplate(placeholders, template.Body) + } if err != nil { return events.NewErrorResult("failed to render email body", err.Error()) } - subject, err := email.RenderTemplate(placeholders, template.Subject.String) + subject, err := email.RenderTextTemplate(placeholders, template.Subject.String) if err != nil { return events.NewErrorResult("failed to render email subject", err.Error()) } From 1cff038ae3967aeccc863caafeef32a4c8f00a80 Mon Sep 17 00:00:00 2001 From: Janis Saldabols Date: Mon, 31 Aug 2026 08:42:23 +0300 Subject: [PATCH 9/9] ILLDEV-466 Correct migration script order --- ...malize_templates.down.sql => 060_normalize_templates.down.sql} | 0 ..._normalize_templates.up.sql => 060_normalize_templates.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename broker/migrations/{059_normalize_templates.down.sql => 060_normalize_templates.down.sql} (100%) rename broker/migrations/{059_normalize_templates.up.sql => 060_normalize_templates.up.sql} (100%) diff --git a/broker/migrations/059_normalize_templates.down.sql b/broker/migrations/060_normalize_templates.down.sql similarity index 100% rename from broker/migrations/059_normalize_templates.down.sql rename to broker/migrations/060_normalize_templates.down.sql diff --git a/broker/migrations/059_normalize_templates.up.sql b/broker/migrations/060_normalize_templates.up.sql similarity index 100% rename from broker/migrations/059_normalize_templates.up.sql rename to broker/migrations/060_normalize_templates.up.sql