Add Helpdesk issue API include serializer

This commit is contained in:
Jason Thistlethwaite
2026-05-04 09:49:42 -04:00
parent ac284d9dc9
commit fba494dada
7 changed files with 442 additions and 0 deletions
@@ -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`.
+153
View File
@@ -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/<non_helpdesk_issue_id>.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 <callum@safetagtracking.com>`.
- `semantic_index inspect preview-redmine --limit 3 --project customer-service`
showed contact metadata on issue, journal, and contact chunks before Qdrant
rebuild.
@@ -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, installed Redmine 3.4.4 environment. Keep entries focused on local behavior,
rollback archives, and LAN test status. 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 ## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage
- Purpose: make helpdesk ticket and message identity first-class for external - Purpose: make helpdesk ticket and message identity first-class for external
@@ -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
@@ -1,4 +1,5 @@
ActionDispatch::Callbacks.to_prepare do ActionDispatch::Callbacks.to_prepare do
require 'redmine_helpdesk/issue_api_serializer'
require 'redmine_helpdesk/patches/issues_controller_patch' require 'redmine_helpdesk/patches/issues_controller_patch'
require 'redmine_helpdesk/patches/journals_controller_patch' require 'redmine_helpdesk/patches/journals_controller_patch'
require 'redmine_helpdesk/patches/attachments_controller_patch' require 'redmine_helpdesk/patches/attachments_controller_patch'
@@ -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
@@ -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