Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
4b630cd
fix(notifications): use cms page url instead of url for method for links
xhyrom Apr 13, 2026
f736c89
feat: new activity types
xhyrom Apr 17, 2026
8334467
Merge branch 'main' into feat/new-activity-types
xhyrom Apr 24, 2026
3ce6e45
feat: mig
xhyrom Apr 26, 2026
7f7b4b1
refactor: cleanup
xhyrom Apr 26, 2026
fa11e90
Fix syncing previous responsible subject
luciajanikova Apr 24, 2026
2cf0659
Update SyncIssueActivityObjectToTriageJob
luciajanikova Apr 23, 2026
c3702de
Fix SyncIssueActivityObjectToTriageJob
luciajanikova Apr 26, 2026
9fc5ebc
Fix SyncIssueActivityObjectToTriageJob
luciajanikova Apr 26, 2026
84aa166
Fix SQL injection vulnerability in PgSearch::Faster fulltext_search
jsuchal Apr 28, 2026
7ff0c6f
fix(comments): sanitize and normalize html
xhyrom Dec 13, 2025
0877e3b
style: lint
xhyrom Dec 13, 2025
46a9ace
feat: add db migration for converting agent, responsible html comment…
xhyrom Dec 13, 2025
a1ceb2b
refactor: simplify
xhyrom Jan 10, 2026
fea4dd5
refactor: simplify
xhyrom Jan 10, 2026
86de8e0
refactor: simplify
xhyrom Jan 10, 2026
ff4be47
refactor: simplify
xhyrom Jan 10, 2026
546471b
refactor: remove unrelated changes
xhyrom Apr 13, 2026
2aee176
Fix CMS pages import
luciajanikova Apr 30, 2026
05a4243
Retry all jobs
crutch Mar 28, 2026
3baeede
Fix rubocop
crutch Mar 28, 2026
086bded
update osm client to new osm responses
celuchmarek May 5, 2026
3bb58b4
rm unused method
celuchmarek May 5, 2026
682a25d
Do not initialize omniauth_providers in development
crutch Mar 28, 2026
5de8324
Use postgres from docker
crutch Mar 28, 2026
6413574
Fill NOTIFICATION_SMTP_USERNAME in env sample
crutch Mar 28, 2026
43f5245
Use full connection string incl. database
crutch Mar 28, 2026
c0cd19e
Remove empty LEGACY_DB_URL from .env.sample
celuchmarek May 6, 2026
4101a65
Remove empty LEGACY_DB_URL from .env.test
celuchmarek May 6, 2026
83b6a4b
Add Múdre mestá to header
luciajanikova May 18, 2026
795fe2b
add postgis handling
celuchmarek May 22, 2026
11cba4b
rm old find_by_address methods
celuchmarek May 22, 2026
dd9c852
update
celuchmarek May 24, 2026
f961c06
use tmp path for the json
celuchmarek May 24, 2026
721ce5e
rm unique index on boundaries
celuchmarek May 24, 2026
7517d59
lint
celuchmarek May 24, 2026
55def4d
Sync previous RS only for issues in resolution process
luciajanikova May 25, 2026
485389c
Set backoffice owner & group for new backoffice tickets
luciajanikova May 25, 2026
8593d8d
use paging in zammad triage group listing
celuchmarek May 25, 2026
797e08e
Issue Updates without author
luciajanikova May 25, 2026
a1ec877
send categories in issue update
celuchmarek May 25, 2026
f2ecd50
parametrize user limits via ENV
celuchmarek Jun 9, 2026
c663b4d
incluse response status in fire webhook error message
celuchmarek Jun 9, 2026
f6232d6
allow all 2xx status codes for webhooks
celuchmarek Jun 10, 2026
ace01f5
raise response body if webhook failed
celuchmarek Jun 10, 2026
c308d79
Merge branch 'main' into feat/new-activity-types
xhyrom Jun 22, 2026
f661a70
feat: state change
xhyrom Jun 24, 2026
62a5619
feat: sent t oresponsible, in progress, waiting and rs change notific…
xhyrom Jun 27, 2026
a9b26b4
refactor: cleanup
xhyrom Jun 27, 2026
d4bd9ec
refactor: cleanup
xhyrom Jun 27, 2026
00b43e9
refactor: cleanup
xhyrom Jun 29, 2026
239e6ae
fix: add schema info
xhyrom Jul 8, 2026
ad27f1f
feat: use 'na' instead of right arrow
xhyrom Jul 8, 2026
ca1c393
Merge branch 'main' into feat/new-activity-types
xhyrom Jul 23, 2026
44f3430
fix: remove issue waiting from notification mailer
xhyrom Aug 11, 2026
a9cf2f8
Merge branch 'main' into feat/new-activity-types
xhyrom Sep 16, 2026
0c7dee2
feat: replace samospráva with zodpovedný subjekt
xhyrom Sep 30, 2026
d9c23d7
feat: shorter texts
xhyrom Oct 1, 2026
3864bef
feat: use arrow right, state unknown badge
xhyrom Oct 1, 2026
5cc185f
Merge remote-tracking branch 'origin/main' into feat/new-activity-types
xhyrom Oct 1, 2026
23a0f6f
test: notification stater
xhyrom Oct 8, 2026
93019a1
feat: history mig
xhyrom Oct 11, 2026
b84d063
feat: db mig
xhyrom Oct 11, 2026
45ea742
fix: mig and aktivita
xhyrom Oct 12, 2026
7f24c3f
refactor: cleanup
xhyrom Oct 12, 2026
e981942
refactor: cleanup
xhyrom Oct 12, 2026
ede802b
merge
xhyrom Oct 12, 2026
b37a000
style: lint
xhyrom Oct 12, 2026
d9600e8
feat: backfill issue state changes job
xhyrom Oct 17, 2026
c67a76a
feat: backfill issue history
xhyrom Oct 17, 2026
6b34ce8
Merge branch 'main' into feat/new-activity-types
xhyrom Oct 17, 2026
b4c6396
test: zammad tests
xhyrom Oct 17, 2026
f9f46c6
Merge branch 'main' into feat/new-activity-types
mirrec Oct 19, 2026
c8d9d6b
Update annotations
mirrec Oct 19, 2026
7eff555
test: rename backfill issue history test so it runs
mirrec Oct 19, 2026
359fd26
docs: describe state and RS history, new emails and backfill job
mirrec Oct 19, 2026
db18df2
fix: notify about the first responsible subject of an issue
mirrec Oct 19, 2026
ad20c18
Merge branch 'main' into feat/new-activity-types
mirrec Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/assets/stylesheets/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ $zamietnuty: rgba(0, 0, 0, 0.2);
$odstupene: rgba(151, 71, 255, 0.7);
$cakajuce: rgba(255, 203, 37, 1);
$uzavrete: rgba(220, 188, 189, 1);
$duplicitny: rgba(220, 188, 189, 1);
$duplicitny: rgba(220, 188, 189, 1);
$neznamy: rgba(241, 245, 249, 1);
72 changes: 71 additions & 1 deletion app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ a.button-transparent:hover {
.state-archivovany { background: variables.$uzavrete; }
.state-duplicitny { background: variables.$zamietnuty; }
.state-caka-na-autora { background: variables.$cakajuce; }
.state-unknown { background: variables.$neznamy; }

.statistics-block-category-komunikacie,
.statistics-block-category-cesty-a-chodniky { background: #C4F8FF; }
Expand Down Expand Up @@ -2585,6 +2586,76 @@ section.podnet .podnet-discussion .comment {
padding: 30px 20px;
word-break: break-word;
}
section.podnet .podnet-discussion .state-change-message {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: 10px 0;
padding: 10px 20px;
}

section.podnet .podnet-discussion .state-change-message .change-icon {
align-items: center;
background: variables.$blue-light;
border-radius: 50%;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
flex-shrink: 0;

svg {
stroke: variables.$blue-dark;
width: 16px;
height: 16px;
}
}

section.podnet .podnet-discussion .state-change-message .content {
align-items: center;
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 10px;
}

section.podnet .podnet-discussion .state-change-message .text {
align-items: center;
color: variables.$black-light;
display: flex;
flex-wrap: wrap;
font-family: variables.$general;
font-size: variables.em(16,16);
gap: 8px;
font-weight: variables.$medium;
}

section.podnet .podnet-discussion .state-change-message .state-badge {
border-radius: 12px;
color: variables.$black;
font-size: variables.em(14,16);
font-weight: variables.$semibold;
padding: 4px 12px;
}

section.podnet .podnet-discussion .state-change-message .arrow-right {
stroke: variables.$black-light;
width: 16px;
height: 16px;
}

section.podnet .podnet-discussion .state-change-message .date {
color: variables.$black-light;
font-size: variables.em(14,16);
margin-left: auto;
}

section.podnet .podnet-discussion .comment-con > *:has(.state-change-message) + * .comment,
section.podnet .podnet-discussion .comment-con > *:has(.state-change-message) + * .edit-comment-form {
border-top: 1px solid variables.$black;
}

section.podnet .podnet-discussion .duplicate {
background: variables.$green-light;
}
Expand Down Expand Up @@ -3514,4 +3585,3 @@ footer .footer-nav-bottom {

// 1600px
@include meta.load-css("_100em.scss");

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module Notifications
class PublishIssueResponsibleSubjectChangedJob < ApplicationJob
def perform(issue, responsible_subject_id_change: [], notification_mailer: NotificationMailer)
return unless responsible_subject_id_change.present?

previous_rs_id = responsible_subject_id_change.first
previous_rs = previous_rs_id.nil? ? nil : ResponsibleSubject.find(previous_rs_id)

issue.subscriptions.active.each do |subscription|
next unless subscription.subscriber.email_notifiable?

notification_mailer.with(subscription: subscription).issue_responsible_subject_changed(previous_rs).deliver_later
end
end
end
end
2 changes: 2 additions & 0 deletions app/jobs/notifications/publish_issue_state_changed_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def perform(issue, state_id_change: [], notification_mailer: NotificationMailer)
notification_mailer.with(subscription: subscription).issue_rejected.deliver_later
when "duplicate"
notification_mailer.with(subscription: subscription).issue_marked_as_duplicate.deliver_later
when "in_progress"
notification_mailer.with(subscription: subscription).issue_in_progress.deliver_later
when "resolved"
notification_mailer.with(subscription: subscription).issue_resolved.deliver_later
when "unresolved"
Expand Down
219 changes: 219 additions & 0 deletions app/jobs/triage/backfill_issue_history_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
class Triage::BackfillIssueHistoryJob < ApplicationJob
TRIAGE_STATE_ATTRIBUTE = "ops_state"
TRIAGE_RESPONSIBLE_SUBJECT_ATTRIBUTE = "responsible_subject"

def perform(issue, triage_zammad_client: TriageZammadEnvironment.client)
ticket_ids = [ issue.triage_external_id, issue.resolution_external_id ].compact.uniq
if ticket_ids.empty?
log(issue, "has no triage ticket, skipped")
return
end
return if state_backfilled?(issue) && responsible_subject_backfilled?(issue)

histories = {}
ticket_ids.each do |ticket_id|
history = triage_zammad_client.get_ticket_history(ticket_id)
if history.nil?
log(issue, "ticket #{ticket_id} not found, skipped")
return
end
histories[ticket_id] = history
end

issue.with_lock do
backfill_state_changes(issue, histories) unless state_backfilled?(issue)
backfill_responsible_subject_changes(issue, histories) unless responsible_subject_backfilled?(issue)
end
end

private

def log(issue, message)
Rails.logger.info("BackfillIssueHistory: issue ##{issue.id} #{message}")
end

def state_backfilled?(issue)
issue.activities.where(type: "Issues::StateChangeActivity").exists?
end

def responsible_subject_backfilled?(issue)
issue.activities.where(type: "Issues::SystemResponsibleSubjectChangeActivity").exists?
end

def backfill_state_changes(issue, histories)
transitions = histories.values.flat_map { |history| state_transitions(history) }
transitions.sort_by! { |entry| entry[:created_at] || issue.created_at }

entries = []
if transitions.any?
first_previous = transitions.first[:previous_state]
entries << { previous_state: nil, new_state: first_previous, created_at: issue.created_at } if first_previous
elsif issue.state
entries << { previous_state: nil, new_state: issue.state, created_at: issue.created_at }
end
entries.concat(transitions)

if entries.empty?
log(issue, "has no mappable state history, skipped")
return
end

last_new_state = transitions.last&.dig(:new_state)
if last_new_state && issue.state_id && last_new_state.id != issue.state_id
log(issue, "portal state (#{issue.state.key}) differs from last Zammad state (#{last_new_state.key}), backfilling Zammad history only")
end

insert_state_changes!(issue, entries)
log(issue, "backfilled #{entries.size} state change(s)")
end

def backfill_responsible_subject_changes(issue, histories)
transitions = histories.values.flat_map { |history| responsible_subject_transitions(history) }
transitions.sort_by! { |entry| entry[:created_at] || issue.created_at }

entries = []
if transitions.any?
first_previous = transitions.first[:previous_responsible_subject]
if first_previous
entries << { previous_responsible_subject: nil, new_responsible_subject: first_previous, created_at: issue.created_at }
end
elsif issue.responsible_subject
entries << { previous_responsible_subject: nil, new_responsible_subject: issue.responsible_subject, created_at: issue.created_at }
end
entries.concat(transitions)

if entries.empty?
log(issue, "has no mappable responsible subject history, skipped")
return
end

last_new_rs = transitions.last&.dig(:new_responsible_subject)
if last_new_rs && issue.responsible_subject_id && last_new_rs.id != issue.responsible_subject_id
log(issue, "portal responsible subject (#{issue.responsible_subject.name}) differs from last Zammad one (#{last_new_rs.name}), backfilling Zammad history only")
end

insert_responsible_subject_changes!(issue, entries)
log(issue, "backfilled #{entries.size} responsible subject change(s)")
end

def state_transitions(history)
history
.select { |entry| entry["type"] == "updated" && entry["object"] == "Ticket" && entry["attribute"] == TRIAGE_STATE_ATTRIBUTE }
.filter_map do |entry|
new_state = find_state(entry["value_to"])

if new_state.nil?
Rails.logger.info("BackfillIssueHistory: unmappable triage state #{entry["value_to"].inspect}, entry skipped")
next
end

previous_state = find_state(entry["value_from"])
next if previous_state == new_state

{ previous_state: previous_state, new_state: new_state, created_at: parse_time(entry["created_at"]) }
end
end

def responsible_subject_transitions(history)
history
.select { |entry| entry["type"] == "updated" && entry["object"] == "Ticket" && entry["attribute"] == TRIAGE_RESPONSIBLE_SUBJECT_ATTRIBUTE }
.filter_map do |entry|
new_rs_id = parse_responsible_subject_id(entry["value_to"])
new_rs = ResponsibleSubject.find_by(id: new_rs_id) if new_rs_id

if new_rs_id && new_rs.nil?
Rails.logger.info("BackfillIssueHistory: unmappable responsible subject #{entry["value_to"].inspect}, entry skipped")
next
end

previous_rs = find_responsible_subject(entry["value_from"])
next if previous_rs == new_rs

{ previous_responsible_subject: previous_rs, new_responsible_subject: new_rs, created_at: parse_time(entry["created_at"]) }
end
end

def find_state(value)
normalized = value.to_s.strip
return if normalized.blank?

Issues::State.find_by(key: normalized) || Issues::State.find_by(name: normalized)
end

def parse_responsible_subject_id(value)
value.to_s[/"value"\s*=>\s*(\d+)/, 1]&.to_i
end

def find_responsible_subject(value)
id = parse_responsible_subject_id(value)
ResponsibleSubject.find_by(id: id) if id
end

def parse_time(value)
Time.zone.parse(value.to_s)
rescue StandardError
nil
end

def insert_state_changes!(issue, entries)
now = Time.current

activity_ids = Issues::Activity.insert_all(
entries.map { |entry|
{
issue_id: issue.id,
type: "Issues::StateChangeActivity",
created_at: entry[:created_at] || now,
updated_at: now
}
},
returning: %w[id]
).rows.flatten

raise "activity/state change count mismatch" if activity_ids.size != entries.size

Issues::StateChange.insert_all(
entries.zip(activity_ids).map { |entry, activity_id|
{
activity_id: activity_id,
previous_state_id: entry[:previous_state]&.id,
new_state_id: entry[:new_state].id,
uuid: SecureRandom.uuid,
created_at: entry[:created_at] || now,
updated_at: now
}
}
)
end

def insert_responsible_subject_changes!(issue, entries)
now = Time.current

activity_ids = Issues::Activity.insert_all(
entries.map { |entry|
{
issue_id: issue.id,
type: "Issues::SystemResponsibleSubjectChangeActivity",
created_at: entry[:created_at] || now,
updated_at: now
}
},
returning: %w[id]
).rows.flatten

raise "activity/responsible subject change count mismatch" if activity_ids.size != entries.size

Issues::SystemResponsibleSubjectChange.insert_all(
entries.zip(activity_ids).map { |entry, activity_id|
{
activity_id: activity_id,
previous_responsible_subject_id: entry[:previous_responsible_subject]&.id,
new_responsible_subject_id: entry[:new_responsible_subject]&.id,
uuid: SecureRandom.uuid,
created_at: entry[:created_at] || now,
updated_at: now
}
}
)
end
end
13 changes: 13 additions & 0 deletions app/lib/zammad_api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ def get_ticket(ticket_id, expand: false, allowed_article_types: DEFAULT_ALLOWED_
end
end

def get_ticket_history(ticket_id)
response = raw_api_request(:get, "ticket_history/#{ticket_id}")
history = response["history"]
raise "Unexpected ticket_history response shape: #{response.keys.inspect}" unless history.is_a?(Array)

history.sort_by { |entry| entry["created_at"].to_s }
rescue RuntimeError => e
raise e unless e.message.include?("Couldn't find Ticket with") || e.message.include?("Request failed with status 404")

Rails.logger.info("Couldn't find history for ticket with id: #{ticket_id}")
nil
end

def create_ticket_from_issue!(issue, issue_number:, process_type: DEFAULT_PROCESS_TYPE, state: nil, group: DEFAULT_GROUP, sender: DEFAULT_SENDER, owner_id: nil)
ops_state = issue.state&.key
if issue.issue_type == "praise" && ops_state == "resolved_private"
Expand Down
10 changes: 10 additions & 0 deletions app/mailers/notification_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,20 @@ def issue_rejected
mail
end

def issue_in_progress
mail
end

def issue_marked_as_duplicate
mail
end

def issue_responsible_subject_changed(previous_rs)
@previous_rs = previous_rs
@new_rs = @issue.responsible_subject
mail
end

def issue_waiting_for_author
mail
end
Expand Down
Loading
Loading