hr_lite 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/app/assets/javascripts/hr_lite/crm_picker.js +4 -2
  4. data/app/assets/javascripts/hr_lite/mention.js +2 -2
  5. data/app/assets/javascripts/hr_lite/photo_crop.js +4 -0
  6. data/app/assets/javascripts/hr_lite/search.js +3 -2
  7. data/app/assets/javascripts/hr_lite/tax_preview.js +3 -2
  8. data/app/controllers/hr_lite/admin/assets_controller.rb +4 -4
  9. data/app/controllers/hr_lite/admin/attendances_controller.rb +1 -1
  10. data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +2 -2
  11. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +4 -4
  12. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +4 -1
  13. data/app/controllers/hr_lite/admin/employees_controller.rb +4 -2
  14. data/app/controllers/hr_lite/admin/expenses_controller.rb +6 -6
  15. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -4
  16. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +4 -4
  17. data/app/controllers/hr_lite/admin/overview_controller.rb +5 -0
  18. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +4 -4
  19. data/app/controllers/hr_lite/admin/resignations_controller.rb +4 -0
  20. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +2 -2
  21. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +4 -4
  22. data/app/controllers/hr_lite/admin/work_locations_controller.rb +3 -3
  23. data/app/controllers/hr_lite/application_controller.rb +5 -0
  24. data/app/controllers/hr_lite/comp_off_requests_controller.rb +2 -2
  25. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -2
  26. data/app/controllers/hr_lite/regularization_requests_controller.rb +2 -2
  27. data/app/controllers/hr_lite/tax_declarations_controller.rb +2 -2
  28. data/app/controllers/hr_lite/wfh_requests_controller.rb +2 -2
  29. data/app/controllers/hr_lite/work_locations_controller.rb +1 -1
  30. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +1 -1
  31. data/app/models/concerns/hr_lite/audited.rb +2 -2
  32. data/app/models/hr_lite/designation_change.rb +4 -0
  33. data/app/models/hr_lite/employee_profile.rb +1 -1
  34. data/app/models/hr_lite/resignation.rb +4 -0
  35. data/app/models/hr_lite/wfh_request.rb +1 -1
  36. data/app/views/hr_lite/admin/overview/index.html.erb +17 -0
  37. data/lib/hr_lite/notifications.rb +5 -5
  38. data/lib/hr_lite/version.rb +1 -1
  39. data/lib/hr_lite.rb +11 -1
  40. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c02612fcf572f2b42eab074882c2c07b975e9e272b6b94eb58b3a40edb00cdc
4
- data.tar.gz: a537c7010fc7e5c5bbfc5e85eaa62d4afb4bc5b04eae953500d64ed5f4d1530e
3
+ metadata.gz: 051c3d4efb869bb1c40f819e884e245c5d45462c2664a62464c84ec2686b750e
4
+ data.tar.gz: 263dbc61a8861a69b06b0b6597542e496feac6fd3bb6eb78cadd04a947681f41
5
5
  SHA512:
6
- metadata.gz: 54ca6bb8b70ce33601c8d163caf80556bdec2c653b0360f4d3ff418b0180a14fe2487292ef83499d989c5b064b919857bb083197789fef628bf42f736544a6c5
7
- data.tar.gz: cf2c5930379f973d7a08870b87e3b3c7c28117e4f09921f5465702117c931195203df20292d4532e4b40ce4708479be770627acf7fa5e0a524791c5c81e1db5a
6
+ metadata.gz: c4b151e5a71926157f4976df9ad3fd55f3b60468c6f0d10f7877d2fc9c2363e1ebe7d5d9a86961660848744a840e9b96f597cc1207d86168f20dd7410096ba60
7
+ data.tar.gz: c457f267dc96c0b89278bf870f431174204cb258879cb68e90b152ef37d224354068732b2b118678e6b1329cf24351c5a26dca8672e120549d83c3b906e622a1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.60.0] - 2026-10-21
11
+
12
+ Background failures reach a person. No migrations.
13
+
14
+ - **"Failed in the background" card on the HR overview.** A failed event email, leadership email or bell, host notify hook, WFH or work-location notice, audit row, onboarding checklist or calendar read used to write only a log line. Each is now recorded once in the audit trail (`failure.<event>`, with the error class, a 200-character message and the affected user) through `HrLite.record_failure`, and the ten most recent show on the overview to anyone who can open the audit trail. Money-tier subjects stay superadmin-only, as on the audit trail.
15
+
16
+ ## [0.59.0] - 2026-10-21
17
+
18
+ No silent failures where a person is waiting on the answer. No migrations. Audit: `docs/audits/silent_failures.md`.
19
+
20
+ - **Decisions say why they failed.** 24 admin and employee actions answered every failed save with a stock line such as "Only pending requests can be decided." They now show the record's own reason, and keep the stock line for the genuine not-pending case.
21
+ - **Offboarding no longer claims access was revoked when it was not.** A failing `offboard_user` hook (offboard, or accepting a resignation on its last day) or `on_designation_change` hook now shows an alert with the cause and what to do by hand.
22
+ - **Search, live tax, lead search and @mentions say when they could not load.** They no longer look like "no results" or show a stale figure. An unreadable ID-card photo (HEIC on desktop) now says so.
23
+
10
24
  ## [0.58.0] - 2026-10-21
11
25
 
12
26
  Leave and birthdays for the whole team, Keka-style. No migrations. Hosts schedule `HrLite::TeamNoticesJob` daily at 10am.
@@ -12,12 +12,14 @@
12
12
  if (q.length < 2) return;
13
13
  timer = setTimeout(function () {
14
14
  fetch(box.dataset.hrlCrm + "?q=" + encodeURIComponent(q), { headers: { Accept: "application/json" }, credentials: "same-origin" })
15
- .then(function (r) { return r.ok ? r.json() : []; })
15
+ .then(function (r) { if (!r.ok) throw r; return r.json(); })
16
16
  .then(function (leads) {
17
17
  select.length = 1;
18
+ select.options[0].text = "No lead";
18
19
  leads.forEach(function (lead) { select.add(new Option(lead.label, lead.id)); });
19
20
  if (leads.length) select.selectedIndex = 1;
20
- });
21
+ })
22
+ .catch(function () { select.length = 1; select.options[0].text = "Lead search failed. Type again to retry."; });
21
23
  }, 250);
22
24
  });
23
25
  }
@@ -59,9 +59,9 @@
59
59
 
60
60
  var lookup = debounce(function (query) {
61
61
  fetch(url + "?q=" + encodeURIComponent(query), { headers: { Accept: "application/json" } })
62
- .then(function (r) { return r.ok ? r.json() : []; })
62
+ .then(function (r) { if (!r.ok) throw r; return r.json(); })
63
63
  .then(renderResults)
64
- .catch(close);
64
+ .catch(function () { close(); menu.textContent = "Could not load names. Keep typing to retry."; menu.hidden = false; });
65
65
  }, 200);
66
66
 
67
67
  textarea.addEventListener("input", function () {
@@ -30,6 +30,10 @@
30
30
  zoom.closest("[data-hrl-photo-adjust]").hidden = false;
31
31
  place();
32
32
  };
33
+ img.onerror = function () {
34
+ input.value = "";
35
+ alert("Could not read that picture. Choose a JPG or PNG photo.");
36
+ };
33
37
  img.src = URL.createObjectURL(file);
34
38
  });
35
39
 
@@ -31,8 +31,9 @@
31
31
  clearTimeout(timer);
32
32
  timer = setTimeout(function () {
33
33
  fetch(form.action + "?frame=1&q=" + encodeURIComponent(input.value), { credentials: "same-origin", cache: "no-store" })
34
- .then(function (response) { return response.ok ? response.text() : ""; })
35
- .then(function (html) { out.innerHTML = html; });
34
+ .then(function (response) { if (!response.ok) throw response; return response.text(); })
35
+ .then(function (html) { out.innerHTML = html; })
36
+ .catch(function () { out.innerHTML = '<p class="hrl-errors" role="alert">Search failed. Check your connection and try again.</p>'; });
36
37
  }, 180);
37
38
  });
38
39
 
@@ -13,8 +13,9 @@
13
13
  Array.from(data.keys()).forEach(function (key) { if (/\[proofs\]/.test(key)) data.delete(key); });
14
14
  data.delete("_method");
15
15
  fetch(form.dataset.hrlTaxPreview, { method: "POST", body: data, credentials: "same-origin", headers: { "Accept": "text/html" } })
16
- .then(function (response) { return response.ok ? response.text() : null; })
17
- .then(function (html) { if (html) panel.innerHTML = html; });
16
+ .then(function (response) { if (!response.ok) throw response; return response.text(); })
17
+ .then(function (html) { panel.innerHTML = html; })
18
+ .catch(function () { panel.innerHTML = '<p class="hrl-errors" role="alert">Could not work out the tax just now. Check your connection; Save still works.</p>'; });
18
19
  }
19
20
  var rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
20
21
  // The limit bar and "₹X of the ₹Y limit counts" follow the amount as typed.
@@ -28,8 +28,8 @@ module HrLite
28
28
  user = HrLite.user_klass.find(params[:user_id])
29
29
  asset.assign_to!(user, actor: hr_current_user)
30
30
  redirect_to admin_assets_path, notice: "#{asset.name} given to #{HrLite.display_name(user)}."
31
- rescue ActiveRecord::RecordInvalid
32
- redirect_to admin_assets_path, alert: "#{asset.name} is already with somebody."
31
+ rescue ActiveRecord::RecordInvalid => e
32
+ redirect_to admin_assets_path, alert: reason_for(e, "#{asset.name} is already with somebody.")
33
33
  end
34
34
 
35
35
  def take_back
@@ -37,8 +37,8 @@ module HrLite
37
37
  asset.return!(actor: hr_current_user, condition: params[:condition_note],
38
38
  status: params[:status].presence_in(Asset::STATUSES) || "available")
39
39
  redirect_to admin_assets_path, notice: "#{asset.name} taken back."
40
- rescue ActiveRecord::RecordInvalid
41
- redirect_to admin_assets_path, alert: "#{asset.name} is not with anybody."
40
+ rescue ActiveRecord::RecordInvalid => e
41
+ redirect_to admin_assets_path, alert: reason_for(e, "#{asset.name} is not with anybody.")
42
42
  end
43
43
 
44
44
  private
@@ -87,7 +87,7 @@ module HrLite
87
87
  notice: "Nothing to remove for that day."
88
88
  end
89
89
 
90
- record.destroy
90
+ record.destroy!
91
91
  # Its late penalties go too, so checking in again is not charged twice.
92
92
  AttendancePenalty.active.where(attendance_record_id: record.id)
93
93
  .find_each { |p| p.update!(removed_at: Time.current, removed_by_id: hr_current_user.id, removal_reason: note) }
@@ -51,8 +51,8 @@ module HrLite
51
51
  def resolve
52
52
  CaReview.find(params[:id]).resolve!(actor: hr_current_user, note: params[:note])
53
53
  redirect_back_to_list notice: "Marked resolved."
54
- rescue ActiveRecord::RecordInvalid
55
- redirect_back_to_list alert: "Only a flagged item can be resolved."
54
+ rescue ActiveRecord::RecordInvalid => e
55
+ redirect_back_to_list alert: reason_for(e, "Only a flagged item can be resolved.")
56
56
  end
57
57
 
58
58
  private
@@ -18,8 +18,8 @@ module HrLite
18
18
  notice: "Approved — #{request.credit_days.to_f} day credited."
19
19
  rescue CompOffRequest::MissingCompOffType, CompOffRequest::StaleOffDay => e
20
20
  redirect_to admin_comp_off_request_path(request), alert: e.message
21
- rescue ActiveRecord::RecordInvalid
22
- redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
21
+ rescue ActiveRecord::RecordInvalid => e
22
+ redirect_to admin_comp_off_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
23
23
  end
24
24
 
25
25
  def reject
@@ -31,8 +31,8 @@ module HrLite
31
31
 
32
32
  request.reject!(actor: hr_current_user, note: note)
33
33
  redirect_to admin_comp_off_requests_path, notice: "Request rejected."
34
- rescue ActiveRecord::RecordInvalid
35
- redirect_to admin_comp_off_request_path(request), alert: "Only pending requests can be decided."
34
+ rescue ActiveRecord::RecordInvalid => e
35
+ redirect_to admin_comp_off_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
36
36
  end
37
37
 
38
38
  private
@@ -12,7 +12,10 @@ module HrLite
12
12
  @change = DesignationChange.new(change_params.merge(
13
13
  user_id: @profile.user_id, created_by_id: hr_current_user.id
14
14
  ))
15
- if @change.save
15
+ if @change.save && @change.hook_error
16
+ redirect_to admin_employee_path(@profile),
17
+ alert: "Role change recorded, but it could not be applied to their login (#{@change.hook_error}) — update their access by hand."
18
+ elsif @change.persisted?
16
19
  redirect_to admin_employee_path(@profile), notice: "Role change recorded."
17
20
  else
18
21
  render :new, status: :unprocessable_entity
@@ -101,13 +101,15 @@ module HrLite
101
101
  # since an unreturned laptop and un-revoked access both outlive the
102
102
  # employment.
103
103
  ChecklistItem.open_for!(@profile.user, kind: "offboarding", anchor_date: exit_date)
104
+ done = "Offboarded — last day #{exit_date.strftime('%d %b %Y')}"
104
105
  begin
105
106
  HrLite.config.offboard_user.call(@profile.user)
106
107
  rescue => e
107
108
  Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
109
+ return redirect_to admin_employee_path(@profile),
110
+ alert: "#{done}, but access was NOT revoked (#{e.message.truncate(120)}) — revoke the login by hand."
108
111
  end
109
- redirect_to admin_employee_path(@profile),
110
- notice: "Offboarded — last day #{exit_date.strftime('%d %b %Y')}, access revoked."
112
+ redirect_to admin_employee_path(@profile), notice: "#{done}, access revoked."
111
113
  rescue ActiveRecord::RecordInvalid => e
112
114
  redirect_to admin_employee_path(@profile), alert: e.record.errors.full_messages.to_sentence
113
115
  end
@@ -15,8 +15,8 @@ module HrLite
15
15
  expense = decidable.find(params[:id])
16
16
  expense.approve!(actor: hr_current_user, note: params[:decision_note].presence)
17
17
  redirect_to admin_expenses_path, notice: "Claim approved."
18
- rescue ActiveRecord::RecordInvalid
19
- redirect_to admin_expenses_path, alert: "Only a submitted claim can be approved."
18
+ rescue ActiveRecord::RecordInvalid => e
19
+ redirect_to admin_expenses_path, alert: reason_for(e, "Only a submitted claim can be approved.")
20
20
  end
21
21
 
22
22
  def reject
@@ -26,8 +26,8 @@ module HrLite
26
26
 
27
27
  expense.reject!(actor: hr_current_user, note: note)
28
28
  redirect_to admin_expenses_path, notice: "Claim rejected."
29
- rescue ActiveRecord::RecordInvalid
30
- redirect_to admin_expenses_path, alert: "Only a submitted claim can be rejected."
29
+ rescue ActiveRecord::RecordInvalid => e
30
+ redirect_to admin_expenses_path, alert: reason_for(e, "Only a submitted claim can be rejected.")
31
31
  end
32
32
 
33
33
  def reimburse
@@ -35,8 +35,8 @@ module HrLite
35
35
  month = parse_month_param(params[:period_month])
36
36
  expense.reimburse!(actor: hr_current_user, period_month: month)
37
37
  redirect_to admin_expenses_path, notice: "Will be paid with #{month.strftime('%B %Y')} payroll."
38
- rescue ActiveRecord::RecordInvalid
39
- redirect_to admin_expenses_path, alert: "Only an approved claim can go on payroll, and only on a month not yet paid."
38
+ rescue ActiveRecord::RecordInvalid => e
39
+ redirect_to admin_expenses_path, alert: reason_for(e, "Only an approved claim can go on payroll, and only on a month not yet paid.")
40
40
  end
41
41
 
42
42
  # The receipt the employee had to upload, for the person deciding; same rules as a document download.
@@ -20,8 +20,8 @@ module HrLite
20
20
  assignee = HrLite.user_klass.find(params[:assignee_id])
21
21
  request.assign!(actor: hr_current_user, assignee: assignee)
22
22
  redirect_to admin_hr_request_path(request), notice: "Assigned to #{HrLite.display_name(assignee)}."
23
- rescue ActiveRecord::RecordInvalid
24
- redirect_to admin_hr_request_path(request), alert: "This request is already settled."
23
+ rescue ActiveRecord::RecordInvalid => e
24
+ redirect_to admin_hr_request_path(request), alert: reason_for(e, "This request is already settled.")
25
25
  end
26
26
 
27
27
  def resolve
@@ -30,8 +30,8 @@ module HrLite
30
30
  redirect_to admin_hr_requests_path, notice: "Answered."
31
31
  rescue ArgumentError
32
32
  redirect_to admin_hr_request_path(request), alert: "Write an answer before resolving."
33
- rescue ActiveRecord::RecordInvalid
34
- redirect_to admin_hr_request_path(request), alert: "This request is already settled."
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ redirect_to admin_hr_request_path(request), alert: reason_for(e, "This request is already settled.")
35
35
  end
36
36
 
37
37
  private
@@ -65,8 +65,8 @@ module HrLite
65
65
  redirect_to admin_leave_request_path(request),
66
66
  alert: "Cannot approve — comp-off credit no longer covers this request."
67
67
  end
68
- rescue ActiveRecord::RecordInvalid
69
- redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
68
+ rescue ActiveRecord::RecordInvalid => e
69
+ redirect_to admin_leave_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
70
70
  end
71
71
 
72
72
  # LeaveRequest#cancellable_by? has always allowed an admin to call off
@@ -96,8 +96,8 @@ module HrLite
96
96
 
97
97
  request.reject!(actor: hr_current_user, note: note)
98
98
  redirect_to admin_leave_requests_path, notice: "Leave rejected."
99
- rescue ActiveRecord::RecordInvalid
100
- redirect_to admin_leave_request_path(request), alert: "Only pending requests can be decided."
99
+ rescue ActiveRecord::RecordInvalid => e
100
+ redirect_to admin_leave_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
101
101
  end
102
102
 
103
103
  private
@@ -6,6 +6,11 @@ module HrLite
6
6
  def index
7
7
  @query = OverviewQuery.new(user_ids: hr_access.visible_user_ids("leave.view"))
8
8
  @kpis = @query.kpis
9
+ # Emails, bells and audit rows that failed in the background; the audit trail's own gate.
10
+ return unless hr_can?("profile.manage", scope: :all)
11
+
12
+ failures = AuditLog.recent.where("action LIKE ?", "failure.%").includes(:subject).limit(SECTION_CAP)
13
+ @failures = hr_superadmin? ? failures : failures.outside_money_tier
9
14
  end
10
15
  end
11
16
  end
@@ -18,8 +18,8 @@ module HrLite
18
18
  rescue RegularizationRequest::InvalidMerge => e
19
19
  redirect_to admin_regularization_request_path(request),
20
20
  alert: "Cannot apply — #{e.message}. Fix the day manually or reject with a note."
21
- rescue ActiveRecord::RecordInvalid
22
- redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
21
+ rescue ActiveRecord::RecordInvalid => e
22
+ redirect_to admin_regularization_request_path(request), alert: reason_for(e, "Only pending tickets can be decided.")
23
23
  end
24
24
 
25
25
  def reject
@@ -31,8 +31,8 @@ module HrLite
31
31
 
32
32
  request.reject!(actor: hr_current_user, note: note)
33
33
  redirect_to admin_regularization_requests_path, notice: "Ticket rejected."
34
- rescue ActiveRecord::RecordInvalid
35
- redirect_to admin_regularization_request_path(request), alert: "Only pending tickets can be decided."
34
+ rescue ActiveRecord::RecordInvalid => e
35
+ redirect_to admin_regularization_request_path(request), alert: reason_for(e, "Only pending tickets can be decided.")
36
36
  end
37
37
 
38
38
  private
@@ -17,6 +17,10 @@ module HrLite
17
17
  "#{hr_display_name(resignation.user)}, so no exit date was recorded — " \
18
18
  "create the profile to clip attendance and payroll."
19
19
  end
20
+ if resignation.access_error
21
+ return redirect_to admin_employees_path,
22
+ alert: "#{notice} But access was NOT revoked (#{resignation.access_error}) — revoke the login by hand."
23
+ end
20
24
  redirect_to admin_employees_path, notice: notice
21
25
  rescue ActiveRecord::RecordInvalid => e
22
26
  # A rejected exit date used to be reported as "not pending", which was
@@ -62,8 +62,8 @@ module HrLite
62
62
  declaration.verify!(actor: hr_current_user, note: "Proof accepted")
63
63
  end
64
64
  redirect_to admin_tax_declarations_path, notice: "Accepted and verified — payroll uses these amounts now."
65
- rescue ActiveRecord::RecordInvalid
66
- redirect_to admin_tax_declaration_path(declaration), alert: "Only a submitted declaration can be accepted."
65
+ rescue ActiveRecord::RecordInvalid => e
66
+ redirect_to admin_tax_declaration_path(declaration), alert: reason_for(e, "Only a submitted declaration can be accepted.")
67
67
  end
68
68
 
69
69
  def update
@@ -49,8 +49,8 @@ module HrLite
49
49
  request = decidable.find(params[:id])
50
50
  request.approve!(actor: hr_current_user, note: params[:decision_note].to_s.strip.presence)
51
51
  redirect_to admin_wfh_requests_path, notice: "WFH approved for #{hr_display_name(request.user)}."
52
- rescue ActiveRecord::RecordInvalid
53
- redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
52
+ rescue ActiveRecord::RecordInvalid => e
53
+ redirect_to admin_wfh_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
54
54
  end
55
55
 
56
56
  def reject
@@ -60,8 +60,8 @@ module HrLite
60
60
 
61
61
  request.reject!(actor: hr_current_user, note: note)
62
62
  redirect_to admin_wfh_requests_path, notice: "WFH rejected."
63
- rescue ActiveRecord::RecordInvalid
64
- redirect_to admin_wfh_request_path(request), alert: "Only pending requests can be decided."
63
+ rescue ActiveRecord::RecordInvalid => e
64
+ redirect_to admin_wfh_request_path(request), alert: reason_for(e, "Only pending requests can be decided.")
65
65
  end
66
66
 
67
67
  # HR changes an approved or rejected request: dates (past allowed) and/or decision, with a reason.
@@ -38,11 +38,11 @@ module HrLite
38
38
  body: note, path: "/work_locations",
39
39
  bell_to: [ location.user ], email_to: [ location.user ])
40
40
  rescue => e
41
- Rails.logger.error("[hr_lite] work location notification failed: #{e.class}: #{e.message}")
41
+ HrLite.record_failure("work_location.#{status}", e, subject: location.user)
42
42
  end
43
43
  redirect_to admin_work_locations_path, notice: "Location #{status}."
44
- rescue ActiveRecord::RecordInvalid
45
- redirect_to admin_work_locations_path, alert: "Only pending locations can be decided."
44
+ rescue ActiveRecord::RecordInvalid => e
45
+ redirect_to admin_work_locations_path, alert: reason_for(e, "Only pending locations can be decided.")
46
46
  end
47
47
  end
48
48
  end
@@ -100,6 +100,11 @@ module HrLite
100
100
  end
101
101
  end
102
102
 
103
+ # The record's own reason when it has one; `fallback` names the usual cause.
104
+ def reason_for(error, fallback)
105
+ error.record.errors.full_messages.to_sentence.presence || fallback
106
+ end
107
+
103
108
  # Strict param parsing: anything that isn't the exact expected format
104
109
  # falls back to today (never 500s on a mangled URL).
105
110
  def parse_month_param(value)
@@ -27,8 +27,8 @@ module HrLite
27
27
  request = CompOffRequest.where(user_id: hr_current_user.id).find(params[:id])
28
28
  request.cancel!(actor: hr_current_user)
29
29
  redirect_to comp_off_requests_path, notice: "Request cancelled."
30
- rescue ActiveRecord::RecordInvalid
31
- redirect_to comp_off_requests_path, alert: "Only pending requests can be cancelled."
30
+ rescue ActiveRecord::RecordInvalid => e
31
+ redirect_to comp_off_requests_path, alert: reason_for(e, "Only pending requests can be cancelled.")
32
32
  end
33
33
 
34
34
  private
@@ -30,8 +30,8 @@ module HrLite
30
30
  request = own.find(params[:id])
31
31
  request.cancel!(actor: hr_current_user)
32
32
  redirect_to hr_requests_path, notice: "Request withdrawn."
33
- rescue ActiveRecord::RecordInvalid
34
- redirect_to hr_requests_path, alert: "That request has already been answered."
33
+ rescue ActiveRecord::RecordInvalid => e
34
+ redirect_to hr_requests_path, alert: reason_for(e, "That request has already been answered.")
35
35
  end
36
36
 
37
37
  private
@@ -23,8 +23,8 @@ module HrLite
23
23
  request = RegularizationRequest.where(user_id: hr_current_user.id).find(params[:id])
24
24
  request.cancel!(actor: hr_current_user)
25
25
  redirect_to regularization_requests_path, notice: "Day fix cancelled."
26
- rescue ActiveRecord::RecordInvalid
27
- redirect_to regularization_requests_path, alert: "Only pending day fixes can be cancelled."
26
+ rescue ActiveRecord::RecordInvalid => e
27
+ redirect_to regularization_requests_path, alert: reason_for(e, "Only pending day fixes can be cancelled.")
28
28
  end
29
29
 
30
30
  private
@@ -54,8 +54,8 @@ module HrLite
54
54
 
55
55
  @declaration.submit!(actor: hr_current_user)
56
56
  redirect_to tax_declaration_path, notice: "Submitted — HR will check the proof."
57
- rescue ActiveRecord::RecordInvalid
58
- redirect_to tax_declaration_path, alert: "Only a draft can be submitted."
57
+ rescue ActiveRecord::RecordInvalid => e
58
+ redirect_to tax_declaration_path, alert: reason_for(e, "Only a draft can be submitted.")
59
59
  end
60
60
 
61
61
  private
@@ -26,8 +26,8 @@ module HrLite
26
26
  def cancel
27
27
  WfhRequest.where(user_id: hr_current_user.id).find(params[:id]).cancel!(actor: hr_current_user)
28
28
  redirect_to wfh_requests_path, notice: "WFH request cancelled."
29
- rescue ActiveRecord::RecordInvalid
30
- redirect_to wfh_requests_path, alert: "Only pending requests can be cancelled."
29
+ rescue ActiveRecord::RecordInvalid => e
30
+ redirect_to wfh_requests_path, alert: reason_for(e, "Only pending requests can be cancelled.")
31
31
  end
32
32
 
33
33
  private
@@ -50,7 +50,7 @@ module HrLite
50
50
  def notify(event, title, path, to)
51
51
  Notifications.publish(event, title: title, path: path, bell_to: to)
52
52
  rescue => e
53
- Rails.logger.error("[hr_lite] work location notification failed: #{e.class}: #{e.message}")
53
+ HrLite.record_failure(event, e, subject: hr_current_user)
54
54
  end
55
55
  end
56
56
  end
@@ -33,7 +33,7 @@ module HrLite
33
33
  .select { |m| m[:id].present? && m[:starts_at] && m[:ends_at] }
34
34
  .each { |m| m[:id] = m[:id].to_s }
35
35
  rescue => e
36
- Rails.logger.warn("[hr_lite] calendar read failed for user #{user.id}: #{e.class}: #{e.message}")
36
+ HrLite.record_failure("calendar_read", e, subject: user)
37
37
  nil
38
38
  end
39
39
 
@@ -37,8 +37,8 @@ module HrLite
37
37
  hr_lite_publish_policy_change(log)
38
38
  rescue => e
39
39
  # The trail is best-effort by design: an audit hiccup must never roll
40
- # back the domain write it describes. Logged loudly instead.
41
- Rails.logger.error("[hr_lite] audit failed: #{e.class}: #{e.message}")
40
+ # back the domain write it describes. Shown to HR admins instead.
41
+ HrLite.record_failure("audit.#{action}", e, subject: self)
42
42
  end
43
43
 
44
44
  def hr_lite_audited_changes(action)
@@ -17,6 +17,9 @@ module HrLite
17
17
  after_create :apply_to_profile
18
18
  after_create :notify
19
19
 
20
+ # Why the host's on_designation_change hook failed, for the screen to say so.
21
+ attr_reader :hook_error
22
+
20
23
  def readonly?
21
24
  persisted? && !destroyed?
22
25
  end
@@ -45,6 +48,7 @@ module HrLite
45
48
  HrLite.config.on_designation_change.call(user, to_designation)
46
49
  rescue => e
47
50
  Rails.logger.error("[hr_lite] on_designation_change failed: #{e.class}: #{e.message}")
51
+ @hook_error = e.message.truncate(120)
48
52
  end
49
53
  end
50
54
 
@@ -234,7 +234,7 @@ module HrLite
234
234
  ChecklistItem.open_for!(user, kind: "onboarding", anchor_date: date_of_joining)
235
235
  rescue => e
236
236
  # A checklist must never be the reason an employee cannot be created.
237
- Rails.logger.error("[hr_lite] onboarding checklist failed: #{e.class}: #{e.message}")
237
+ HrLite.record_failure("onboarding_checklist", e, subject: user)
238
238
  end
239
239
  end
240
240
  end
@@ -68,6 +68,9 @@ module HrLite
68
68
  end
69
69
 
70
70
  # False when the person had no employee profile to stamp — see accept!.
71
+ # Why the host could not revoke access, for the screen to say so.
72
+ attr_reader :access_error
73
+
71
74
  def exit_date_recorded?
72
75
  @exit_date_recorded
73
76
  end
@@ -82,6 +85,7 @@ module HrLite
82
85
  HrLite.config.offboard_user.call(user)
83
86
  rescue => e
84
87
  Rails.logger.error("[hr_lite] offboard_user failed: #{e.class}: #{e.message}")
88
+ @access_error = e.message.truncate(120)
85
89
  end
86
90
 
87
91
  def notify_submitted
@@ -96,7 +96,7 @@ module HrLite
96
96
  Notifications.publish(event, title: title, body: body.presence, path: path, bell_to: to,
97
97
  email_to: event == "wfh.requested" ? [] : to)
98
98
  rescue => e
99
- Rails.logger.error("[hr_lite] wfh notification failed: #{e.class}: #{e.message}")
99
+ HrLite.record_failure(event, e, subject: user)
100
100
  end
101
101
 
102
102
  def dates_in_window
@@ -12,6 +12,23 @@
12
12
  <div class="hrl-kpi"><div class="hrl-kpi__value"><%= @kpis[:missing_checkout] %></div><div class="hrl-kpi__label">Missing checkouts</div></div>
13
13
  </div>
14
14
 
15
+ <% if @failures.present? %>
16
+ <section class="hrl-card">
17
+ <h2 class="hrl-card__title">Failed in the background</h2>
18
+ <% @failures.each do |log| %>
19
+ <div class="hrl-feed__item">
20
+ <div class="hrl-feed__body">
21
+ <strong><%= log.action.delete_prefix("failure.").tr("._", " ").humanize %></strong>
22
+ — <%= log.subject.is_a?(HrLite.user_klass) ? hr_display_name(log.subject) : "#{log.subject_type.demodulize.underscore.humanize} ##{log.subject_id}" %>,
23
+ <%= log.created_at.strftime("%d %b, %H:%M") %>
24
+ <div class="hrl-small hrl-muted"><%= log.audited_changes["error"] %>: <%= log.audited_changes["message"] %></div>
25
+ </div>
26
+ </div>
27
+ <% end %>
28
+ <a class="hrl-card__link" href="<%= hr_lite.admin_audit_logs_path %>">Audit trail</a>
29
+ </section>
30
+ <% end %>
31
+
15
32
  <% if pending.zero? && @kpis.except(:pending).values.all?(&:zero?) %>
16
33
  <p class="hrl-card hrl-card--empty">All present and accounted for.</p>
17
34
  <% else %>
@@ -110,7 +110,7 @@ module HrLite
110
110
  lead_path = leadership[:path] || path
111
111
 
112
112
  deliver_bells(event, row, bell_to, title, body, path)
113
- deliver_emails(row, email_to, title, body, path, lines, link_url)
113
+ deliver_emails(event, row, email_to, title, body, path, lines, link_url)
114
114
  return if skip_leadership
115
115
 
116
116
  deliver_leadership_email(event, row, lead_title, lead_body, lead_path, lines, diff)
@@ -128,7 +128,7 @@ module HrLite
128
128
  end
129
129
  end
130
130
 
131
- def deliver_emails(row, email_to, title, body, path, lines, link_url = nil)
131
+ def deliver_emails(event, row, email_to, title, body, path, lines, link_url = nil)
132
132
  return unless row[:email]
133
133
 
134
134
  # Rescued PER RECIPIENT. Wrapping the whole loop meant one bad address
@@ -147,7 +147,7 @@ module HrLite
147
147
  held = held_until[user.id]
148
148
  held ? mail.deliver_later(wait_until: held) : mail.deliver_later
149
149
  rescue => e
150
- Rails.logger.error("[hr_lite] event email failed: #{e.class}: #{e.message}")
150
+ HrLite.record_failure("#{event}.email", e, subject: user)
151
151
  end
152
152
  end
153
153
  end
@@ -162,7 +162,7 @@ module HrLite
162
162
  body: body, lines: lines, diff: diff, path: path, event: event.to_s)
163
163
  .deliver_later
164
164
  rescue => e
165
- Rails.logger.error("[hr_lite] leadership email failed: #{e.class}: #{e.message}")
165
+ HrLite.record_failure("#{event}.leadership_email", e)
166
166
  end
167
167
 
168
168
  def deliver_leadership_bells(event, row, bell_to, title, body, path)
@@ -175,7 +175,7 @@ module HrLite
175
175
  HrLite.notify(user: leader, kind: event.to_s, title: title, body: body, path: path)
176
176
  end
177
177
  rescue => e
178
- Rails.logger.error("[hr_lite] leadership bell failed: #{e.class}: #{e.message}")
178
+ HrLite.record_failure("#{event}.leadership_bell", e)
179
179
  end
180
180
  end
181
181
  end
@@ -1,3 +1,3 @@
1
1
  module HrLite
2
- VERSION = "0.58.0"
2
+ VERSION = "0.60.0"
3
3
  end
data/lib/hr_lite.rb CHANGED
@@ -173,8 +173,18 @@ module HrLite
173
173
  def notify(user:, kind:, title:, body: nil, path: nil)
174
174
  config.notify.call(user: user, kind: kind, title: title, body: body, path: path)
175
175
  rescue => e
176
- Rails.logger.error("[hr_lite] notify failed: #{e.class}: #{e.message}")
176
+ record_failure("notify", e, subject: user)
177
177
  nil
178
178
  end
179
+
180
+ # A background side effect failed: log it and keep a row that HR admins
181
+ # see on the overview, so nothing fails where nobody looks. Never raises.
182
+ def record_failure(event, error, subject: nil)
183
+ Rails.logger.error("[hr_lite] #{event} failed: #{error.class}: #{error.message}")
184
+ AuditLog.create!(actor: Current.actor, action: "failure.#{event}", subject_type: subject ? subject.class.name : config.user_class,
185
+ subject_id: subject&.id || 0, audited_changes: { "error" => error.class.name, "message" => error.message.to_s.truncate(200) })
186
+ rescue => e
187
+ Rails.logger.error("[hr_lite] recording a failure failed: #{e.class}: #{e.message}")
188
+ end
179
189
  end
180
190
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hr_lite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.58.0
4
+ version: 0.60.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kshitiz sinha