From fba494dadaf76b35b94e470b0a76f03d975a9c1a Mon Sep 17 00:00:00 2001 From: Jason Thistlethwaite Date: Mon, 4 May 2026 09:49:42 -0400 Subject: [PATCH] Add Helpdesk issue API include serializer --- ...esk-issue-api-20260425T094236Z.MANIFEST.md | 44 +++++ docs/redmine_issue_api_helpdesk_include.md | 153 ++++++++++++++++++ .../LOCAL_CHANGELOG.md | 15 ++ .../app/views/issues/show.api.rsb | 107 ++++++++++++ .../lib/redmine_helpdesk.rb | 1 + .../redmine_helpdesk/issue_api_serializer.rb | 76 +++++++++ .../test_issue_api_serializer.rb | 46 ++++++ 7 files changed, 442 insertions(+) create mode 100644 dist/redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z.MANIFEST.md create mode 100644 docs/redmine_issue_api_helpdesk_include.md create mode 100644 plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb create mode 100644 plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb create mode 100644 tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb diff --git a/dist/redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z.MANIFEST.md b/dist/redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z.MANIFEST.md new file mode 100644 index 0000000..579f068 --- /dev/null +++ b/dist/redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z.MANIFEST.md @@ -0,0 +1,44 @@ +# redmine_contacts_helpdesk 3.0.9 Helpdesk Issue API Local Patch + +- Patch set: `redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z` +- Created: `2026-04-25T09:42:36Z` +- Purpose: production install manifest for the local `include=helpdesk` issue + API extension. + +## Files To Install + +```text +plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb +plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb +plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb +plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md +``` + +## Behavior + +`GET /issues/:id.json?include=journals,helpdesk` keeps the normal Redmine issue +API response and adds Helpdesk ticket/contact metadata when the issue is also a +Helpdesk ticket. Ordinary issues must continue to respond successfully. + +## Validation + +Local checks: + +```sh +ruby tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb +ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb +ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb +ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb +``` + +LAN validation on `192.168.50.170` passed on 2026-04-25: + +```text +/issues/39779.json?include=journals,helpdesk + helpdesk_ticket.contact.id = 1890 + helpdesk_ticket.contact.name = Callum Mackeonis + helpdesk_ticket.contact.email = callum@safetagtracking.com +``` + +Production install and rollback details are documented in +`docs/redmine_issue_api_helpdesk_include.md`. diff --git a/docs/redmine_issue_api_helpdesk_include.md b/docs/redmine_issue_api_helpdesk_include.md new file mode 100644 index 0000000..b0643ce --- /dev/null +++ b/docs/redmine_issue_api_helpdesk_include.md @@ -0,0 +1,153 @@ +# Redmine Issue API Helpdesk Include Patch + +This repository carries a local RedmineUP Helpdesk API extension so external +indexers can keep Redmine issues as the canonical object while still seeing +Helpdesk customer metadata. + +## Behavior + +`GET /issues/:id.json?include=journals,helpdesk` returns the normal Redmine +issue API payload. When the issue also has a Helpdesk ticket, the issue object +includes: + +```json +{ + "helpdesk_ticket": { + "id": 35159, + "contact_id": 1890, + "message_id": "...", + "source": 0, + "is_incoming": true, + "from_address": "customer@example.com", + "to_address": "contact@ldrprep.com", + "cc_address": "", + "ticket_date": "2026-04-14T10:18:38Z", + "contact": { + "id": 1890, + "name": "Customer Name", + "company": "Customer Company", + "email": "customer@example.com" + } + } +} +``` + +For ordinary non-Helpdesk issues, callers must tolerate either +`"helpdesk_ticket": null` or an omitted `helpdesk_ticket` field. The semantic +indexer treats both as ordinary issue data with no Helpdesk contact metadata. + +## Patch Locations + +- `plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb` + overrides Redmine 3.4.4's issue API view and adds the optional + `include=helpdesk` block. +- `plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb` + serializes Helpdesk ticket/contact fields without changing controller logic. +- `plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb` + loads the serializer during plugin preparation. + +## Production Install Checklist + +Install these files into the production Redmine tree, preserving paths: + +```text +plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb +plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb +plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb +plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md +``` + +Before copying, create a production rollback directory and preserve any existing +versions of those files: + +```sh +stamp=$(date -u +%Y%m%dT%H%M%SZ) +backup="$HOME/redmine-plugin-backups/helpdesk-issue-api-include-$stamp" +mkdir -p "$backup" +cd /usr/share/redmine +for path in \ + plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb \ + plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb \ + plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb \ + plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md +do + if [ -e "$path" ]; then + mkdir -p "$backup/$(dirname "$path")" + cp -a "$path" "$backup/$path" + fi +done +printf 'backup=%s\n' "$backup" +``` + +After copying, run syntax checks on the production host: + +```sh +cd /usr/share/redmine +ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb +ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb +ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb +``` + +Reload Passenger: + +```sh +cd /usr/share/redmine +touch tmp/restart.txt +``` + +Then validate one known Helpdesk issue and one ordinary issue: + +```sh +curl -sS -H "X-Redmine-API-Key: $REDMINE_API_KEY" \ + "$REDMINE_URL/issues/39779.json?include=journals,helpdesk" | jq '.issue.helpdesk_ticket' + +curl -sS -H "X-Redmine-API-Key: $REDMINE_API_KEY" \ + "$REDMINE_URL/issues/.json?include=helpdesk" | jq '.issue.helpdesk_ticket' +``` + +The Helpdesk issue must include a ticket object with `contact.id`, +`contact.name`, and `contact.email`. The non-Helpdesk issue should not error; +`helpdesk_ticket` may be `null` or absent. + +Rollback is copying the backup files over the deployed files and touching +`tmp/restart.txt` again. If a file did not exist before deployment, remove it +during rollback. + +## Reapplying After Redmine Core Upgrade + +1. Compare the upgraded Redmine `app/views/issues/show.api.rsb` against this + repository's override. +2. Copy any new upstream issue API fields into the plugin override. +3. Keep the `include_in_api_response?('helpdesk')` block after the issue + timestamps and before optional child/attachment/relation/journal sections. +4. Run: + +```sh +ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb +ruby tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb +``` + +5. On the LAN test instance, confirm: + +```sh +curl -H "X-Redmine-API-Key: $REDMINE_API_KEY" \ + "http://192.168.50.170/issues/39779.json?include=journals,helpdesk" +``` + +The response should include `helpdesk_ticket.contact.id`, +`helpdesk_ticket.contact.name`, and `helpdesk_ticket.contact.email` for a +known Helpdesk issue. + +## LAN Test Result + +On 2026-04-25, the patch was deployed to the LAN Redmine copy at +`192.168.50.170`. + +- Remote backup: + `/home/reddev/redmine-plugin-backups/helpdesk-issue-api-include-20260425T094236Z` +- Syntax checks passed on the LAN host for the loader, serializer, and API view. +- `GET /issues/39779.json?include=journals,helpdesk` returned contact + `#1890 Callum Mackeonis `. +- `semantic_index inspect preview-redmine --limit 3 --project customer-service` + showed contact metadata on issue, journal, and contact chunks before Qdrant + rebuild. diff --git a/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md b/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md index e05b2cc..33653d2 100644 --- a/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md +++ b/plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md @@ -4,6 +4,21 @@ This RedmineUP helpdesk plugin is maintained as local legacy code for the installed Redmine 3.4.4 environment. Keep entries focused on local behavior, rollback archives, and LAN test status. +## 2026-04-25 - Issue API Helpdesk Contact Include + +- Purpose: let external semantic indexing keep Redmine issues as the canonical + object while still receiving Helpdesk ticket/contact metadata. +- Touched behavior: + - Added `include=helpdesk` support to the Redmine issue API override. + - Added a Helpdesk issue API serializer for ticket/customer fields. +- Upgrade note: see `docs/redmine_issue_api_helpdesk_include.md` before + updating Redmine core issue API views. +- LAN test result: + - Deployed to `192.168.50.170` with rollback backup + `/home/reddev/redmine-plugin-backups/helpdesk-issue-api-include-20260425T094236Z`. + - Confirmed `/issues/39779.json?include=journals,helpdesk` returns + `helpdesk_ticket.contact` with id, name, company, and email. + ## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage - Purpose: make helpdesk ticket and message identity first-class for external diff --git a/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb b/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb new file mode 100644 index 0000000..5f3f864 --- /dev/null +++ b/plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb @@ -0,0 +1,107 @@ +api.issue do + api.id @issue.id + api.project(:id => @issue.project_id, :name => @issue.project.name) unless @issue.project.nil? + api.tracker(:id => @issue.tracker_id, :name => @issue.tracker.name) unless @issue.tracker.nil? + api.status(:id => @issue.status_id, :name => @issue.status.name) unless @issue.status.nil? + api.priority(:id => @issue.priority_id, :name => @issue.priority.name) unless @issue.priority.nil? + api.author(:id => @issue.author_id, :name => @issue.author.name) unless @issue.author.nil? + api.assigned_to(:id => @issue.assigned_to_id, :name => @issue.assigned_to.name) unless @issue.assigned_to.nil? + api.category(:id => @issue.category_id, :name => @issue.category.name) unless @issue.category.nil? + api.fixed_version(:id => @issue.fixed_version_id, :name => @issue.fixed_version.name) unless @issue.fixed_version.nil? + api.parent(:id => @issue.parent_id) unless @issue.parent.nil? + + api.subject @issue.subject + api.description @issue.description + api.start_date @issue.start_date + api.due_date @issue.due_date + api.done_ratio @issue.done_ratio + api.is_private @issue.is_private + api.estimated_hours @issue.estimated_hours + api.total_estimated_hours @issue.total_estimated_hours + if User.current.allowed_to?(:view_time_entries, @project) + api.spent_hours(@issue.spent_hours) + api.total_spent_hours(@issue.total_spent_hours) + end + + render_api_custom_values @issue.visible_custom_field_values, api + + api.created_on @issue.created_on + api.updated_on @issue.updated_on + api.closed_on @issue.closed_on + + if include_in_api_response?('helpdesk') + helpdesk_ticket = RedmineHelpdesk::IssueApiSerializer.serialize(@issue) + if helpdesk_ticket + api.helpdesk_ticket do + api.id helpdesk_ticket[:id] + api.contact_id helpdesk_ticket[:contact_id] + api.message_id helpdesk_ticket[:message_id] + api.source helpdesk_ticket[:source] + api.is_incoming helpdesk_ticket[:is_incoming] + api.from_address helpdesk_ticket[:from_address] + api.to_address helpdesk_ticket[:to_address] + api.cc_address helpdesk_ticket[:cc_address] + api.ticket_date helpdesk_ticket[:ticket_date] + if helpdesk_ticket[:contact] + api.contact do + api.id helpdesk_ticket[:contact][:id] + api.name helpdesk_ticket[:contact][:name] + api.company helpdesk_ticket[:contact][:company] + api.email helpdesk_ticket[:contact][:email] + end + end + end + else + api.helpdesk_ticket nil + end + end + + render_api_issue_children(@issue, api) if include_in_api_response?('children') + + api.array :attachments do + @issue.attachments.each do |attachment| + render_api_attachment(attachment, api) + end + end if include_in_api_response?('attachments') + + api.array :relations do + @relations.each do |relation| + api.relation(:id => relation.id, :issue_id => relation.issue_from_id, :issue_to_id => relation.issue_to_id, :relation_type => relation.relation_type, :delay => relation.delay) + end + end if include_in_api_response?('relations') && @relations.present? + + api.array :changesets do + @changesets.each do |changeset| + api.changeset :revision => changeset.revision do + api.user(:id => changeset.user_id, :name => changeset.user.name) unless changeset.user.nil? + api.comments changeset.comments + api.committed_on changeset.committed_on + end + end + end if include_in_api_response?('changesets') + + api.array :journals do + @journals.each do |journal| + api.journal :id => journal.id do + api.user(:id => journal.user_id, :name => journal.user.name) unless journal.user.nil? + api.notes journal.notes + api.created_on journal.created_on + api.private_notes journal.private_notes + api.array :details do + journal.visible_details.each do |detail| + api.detail :property => detail.property, :name => detail.prop_key do + api.old_value detail.old_value + api.new_value detail.value + end + end + end + end + end + end if include_in_api_response?('journals') + + api.array :watchers do + @issue.watcher_users.each do |user| + api.user :id => user.id, :name => user.name + end + end if include_in_api_response?('watchers') && User.current.allowed_to?(:view_issue_watchers, @issue.project) +end diff --git a/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb b/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb index 25474da..af3493b 100644 --- a/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb +++ b/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb @@ -1,4 +1,5 @@ ActionDispatch::Callbacks.to_prepare do + require 'redmine_helpdesk/issue_api_serializer' require 'redmine_helpdesk/patches/issues_controller_patch' require 'redmine_helpdesk/patches/journals_controller_patch' require 'redmine_helpdesk/patches/attachments_controller_patch' diff --git a/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb b/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb new file mode 100644 index 0000000..040f8d5 --- /dev/null +++ b/plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb @@ -0,0 +1,76 @@ +require 'time' + +module RedmineHelpdesk + module IssueApiSerializer + module_function + + def serialize(issue) + ticket = safe_send(issue, :helpdesk_ticket) + return nil unless ticket + + contact = safe_send(ticket, :customer) + contact_id = safe_send(ticket, :contact_id) || safe_send(contact, :id) + contact_email = primary_email(contact) + + { + :id => safe_send(ticket, :id), + :contact_id => contact_id, + :message_id => safe_send(ticket, :message_id), + :source => safe_send(ticket, :source), + :is_incoming => boolean_send(ticket, :is_incoming?), + :from_address => safe_send(ticket, :from_address), + :to_address => safe_send(ticket, :to_address), + :cc_address => safe_send(ticket, :cc_address), + :ticket_date => iso8601(safe_send(ticket, :ticket_date)), + :contact => contact_payload(contact, contact_id, contact_email) + } + end + + def contact_payload(contact, contact_id, contact_email) + return nil unless contact || contact_id || contact_email + + payload = { + :id => contact_id, + :name => safe_send(contact, :name), + :company => safe_send(contact, :company), + :email => contact_email + } + reject_blank_values(payload) + end + + def primary_email(contact) + email = safe_send(contact, :primary_email) + return email unless blank?(email) + + emails = safe_send(contact, :emails) + emails.respond_to?(:first) ? emails.first : nil + end + + def iso8601(value) + return nil if blank?(value) + value.respond_to?(:utc) ? value.utc.iso8601 : value.to_s + end + + def boolean_send(object, method_name) + return nil unless object && object.respond_to?(method_name) + object.public_send(method_name) ? true : false + end + + def safe_send(object, method_name) + return nil unless object && object.respond_to?(method_name) + object.public_send(method_name) + end + + def reject_blank_values(payload) + result = {} + payload.each do |key, value| + result[key] = value unless blank?(value) + end + result + end + + def blank?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + end +end diff --git a/tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb b/tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb new file mode 100644 index 0000000..e22ab27 --- /dev/null +++ b/tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb @@ -0,0 +1,46 @@ +require 'minitest/autorun' +require_relative '../../plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer' + +Contact = Struct.new(:id, :name, :company, :primary_email, :emails, keyword_init: true) +Ticket = Struct.new(:id, :contact_id, :message_id, :source, :from_address, :to_address, :cc_address, :ticket_date, :customer, keyword_init: true) do + def is_incoming? + true + end +end +Issue = Struct.new(:helpdesk_ticket, keyword_init: true) + +class IssueApiSerializerTest < Minitest::Test + def test_serializes_helpdesk_ticket_with_expanded_contact + contact = Contact.new( + :id => 1890, + :name => 'Callum Mackeonis', + :company => 'SafeTag Tracking', + :primary_email => 'callum@safetagtracking.com', + :emails => ['callum@safetagtracking.com'] + ) + ticket = Ticket.new( + :id => 35159, + :contact_id => 1890, + :message_id => 'message-id', + :source => 0, + :from_address => 'callum@safetagtracking.com', + :to_address => 'contact@ldrprep.com', + :cc_address => '', + :ticket_date => Time.utc(2026, 4, 14, 10, 18, 38), + :customer => contact + ) + + payload = RedmineHelpdesk::IssueApiSerializer.serialize(Issue.new(:helpdesk_ticket => ticket)) + + assert_equal 35159, payload[:id] + assert_equal 1890, payload[:contact_id] + assert_equal 'callum@safetagtracking.com', payload[:contact][:email] + assert_equal 'Callum Mackeonis', payload[:contact][:name] + assert_equal 'SafeTag Tracking', payload[:contact][:company] + assert_equal '2026-04-14T10:18:38Z', payload[:ticket_date] + end + + def test_returns_nil_for_non_helpdesk_issue + assert_nil RedmineHelpdesk::IssueApiSerializer.serialize(Issue.new(:helpdesk_ticket => nil)) + end +end