Add Helpdesk issue API include serializer
This commit is contained in:
+44
@@ -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`.
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user