hr_lite 0.58.0 → 0.61.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +14 -0
  4. data/app/assets/javascripts/hr_lite/crm_picker.js +4 -2
  5. data/app/assets/javascripts/hr_lite/mention.js +2 -2
  6. data/app/assets/javascripts/hr_lite/photo_crop.js +4 -0
  7. data/app/assets/javascripts/hr_lite/search.js +3 -2
  8. data/app/assets/javascripts/hr_lite/tax_preview.js +3 -2
  9. data/app/assets/stylesheets/hr_lite/hr_lite.css +20 -0
  10. data/app/controllers/hr_lite/admin/assets_controller.rb +4 -4
  11. data/app/controllers/hr_lite/admin/attendances_controller.rb +1 -1
  12. data/app/controllers/hr_lite/admin/ca_reviews_controller.rb +2 -2
  13. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +4 -4
  14. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +4 -1
  15. data/app/controllers/hr_lite/admin/employees_controller.rb +4 -2
  16. data/app/controllers/hr_lite/admin/expenses_controller.rb +6 -6
  17. data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +88 -0
  18. data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +27 -0
  19. data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
  20. data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
  21. data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +45 -0
  22. data/app/controllers/hr_lite/admin/hr_requests_controller.rb +4 -4
  23. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +4 -4
  24. data/app/controllers/hr_lite/admin/overview_controller.rb +5 -0
  25. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +4 -4
  26. data/app/controllers/hr_lite/admin/resignations_controller.rb +4 -0
  27. data/app/controllers/hr_lite/admin/tax_declarations_controller.rb +2 -2
  28. data/app/controllers/hr_lite/admin/wfh_requests_controller.rb +4 -4
  29. data/app/controllers/hr_lite/admin/work_locations_controller.rb +3 -3
  30. data/app/controllers/hr_lite/application_controller.rb +5 -0
  31. data/app/controllers/hr_lite/comp_off_requests_controller.rb +2 -2
  32. data/app/controllers/hr_lite/hr_requests_controller.rb +2 -2
  33. data/app/controllers/hr_lite/regularization_requests_controller.rb +2 -2
  34. data/app/controllers/hr_lite/tax_declarations_controller.rb +2 -2
  35. data/app/controllers/hr_lite/wfh_requests_controller.rb +2 -2
  36. data/app/controllers/hr_lite/work_locations_controller.rb +1 -1
  37. data/app/helpers/hr_lite/application_helper.rb +38 -0
  38. data/app/jobs/hr_lite/calendar_meeting_sync_job.rb +1 -1
  39. data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
  40. data/app/models/concerns/hr_lite/audited.rb +2 -2
  41. data/app/models/hr_lite/designation_change.rb +4 -0
  42. data/app/models/hr_lite/employee_profile.rb +1 -1
  43. data/app/models/hr_lite/hiring_campaign.rb +21 -0
  44. data/app/models/hr_lite/hiring_candidate.rb +52 -0
  45. data/app/models/hr_lite/hiring_interview.rb +13 -0
  46. data/app/models/hr_lite/hiring_message.rb +14 -0
  47. data/app/models/hr_lite/hiring_template.rb +11 -0
  48. data/app/models/hr_lite/resignation.rb +4 -0
  49. data/app/models/hr_lite/wfh_request.rb +1 -1
  50. data/app/services/hr_lite/hiring_import.rb +106 -0
  51. data/app/services/hr_lite/hiring_merge.rb +48 -0
  52. data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +18 -0
  53. data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
  54. data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
  55. data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
  56. data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
  57. data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
  58. data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +98 -0
  59. data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +145 -0
  60. data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
  61. data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +16 -0
  62. data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +40 -0
  63. data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
  64. data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
  65. data/app/views/hr_lite/admin/overview/index.html.erb +17 -0
  66. data/config/routes.rb +10 -0
  67. data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
  68. data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
  69. data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
  70. data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
  71. data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
  72. data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
  73. data/lib/hr_lite/configuration.rb +14 -1
  74. data/lib/hr_lite/notifications.rb +5 -5
  75. data/lib/hr_lite/permissions.rb +2 -1
  76. data/lib/hr_lite/role_seeds.rb +2 -2
  77. data/lib/hr_lite/version.rb +1 -1
  78. data/lib/hr_lite.rb +17 -1
  79. metadata +33 -1
@@ -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
@@ -88,6 +88,10 @@ module HrLite
88
88
  { label: "Help desk", path: :admin_hr_requests_path, match: [ "/admin/hr_requests" ], if: -> { hr_can?("hr_request.manage", scope: :all) } },
89
89
  { label: "Manage policies", path: :admin_policies_path, match: [ "/admin/policies" ], if: -> { hr_can?("policy.manage", scope: :all) } }
90
90
  ] },
91
+ { label: "Hiring", tabs: [
92
+ { label: "Campaigns", path: :admin_hiring_campaigns_path, match: [ "/admin/hiring_campaigns", "/admin/hiring_candidates" ], if: -> { hr_can?("hiring.manage", scope: :all) } },
93
+ { label: "Email templates", path: :admin_hiring_templates_path, match: [ "/admin/hiring_templates" ], if: -> { hr_can?("hiring.manage", scope: :all) } }
94
+ ] },
91
95
  { label: "Admin", divider: true, tabs: [
92
96
  { label: "Settings", path: :admin_leave_types_path, match: [ "/admin/leave_types", "/admin/office_locations", "/admin/holidays", "/admin/expense_categories" ], if: LEAD },
93
97
  { label: "HR settings", path: :edit_admin_setting_path, match: [ "/admin/setting" ], if: LEAD },
@@ -194,6 +198,7 @@ module HrLite
194
198
  "People" => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
195
199
  "Me" => '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
196
200
  "Help" => '<circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/>',
201
+ "Hiring" => '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
197
202
  "Admin" => '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
198
203
  "More" => '<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>',
199
204
  "Search" => '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'
@@ -253,6 +258,39 @@ module HrLite
253
258
  hr_lite.public_send(helper, approval.subject_id)
254
259
  end
255
260
 
261
+ HIRING_BADGES = { "imported" => "muted", "invited" => "info", "interested" => "ok", "not_interested" => "bad",
262
+ "interview" => "warn", "selected" => "ok", "rejected" => "bad", "on_hold" => "muted" }.freeze
263
+
264
+ def hrl_hiring_badge(candidate)
265
+ modifier = HIRING_BADGES.fetch(candidate.stage)
266
+ tag.span(candidate.stage_label, class: [ "hrl-badge", ("hrl-badge--#{modifier}" unless modifier == "info") ])
267
+ end
268
+
269
+ # The five steps of the candidate stepper: [label, :done|:now|:todo].
270
+ HIRING_STEPS = %w[Imported Invited Interested Interview Decision].freeze
271
+ HIRING_STEP_OF = { "imported" => 0, "invited" => 1, "interested" => 2, "not_interested" => 2, "interview" => 3,
272
+ "selected" => 4, "rejected" => 4, "on_hold" => 4 }.freeze
273
+
274
+ def hrl_hiring_steps(candidate)
275
+ at = HIRING_STEP_OF.fetch(candidate.stage)
276
+ HIRING_STEPS.each_with_index.map do |label, i|
277
+ label = candidate.stage_label if i == at && %w[not_interested interview selected rejected on_hold].include?(candidate.stage)
278
+ [ label, i < at ? :done : (i == at ? :now : :todo) ]
279
+ end
280
+ end
281
+
282
+ # One line per hiring timeline row, from the whitelisted changes it stored.
283
+ def hrl_hiring_event(log)
284
+ c = log.audited_changes.to_h
285
+ at = c["starts_at"] && Time.zone.parse(c["starts_at"]).strftime("%-d %b %Y, %-l:%M %p")
286
+ case log.action.delete_prefix("hiring.")
287
+ when "stage" then [ "Moved to #{c['to'].to_s.humanize}", c["reason"] ].compact.join(": ")
288
+ when /\Aemail_(\w+)\z/ then "Email #{Regexp.last_match(1)}: #{c['template']}"
289
+ when /\Ainterview_(\w+)\z/ then [ "Round #{c['round']} #{Regexp.last_match(1).tr('_', ' ')}", at ].compact.join(" for ")
290
+ else log.action.delete_prefix("hiring.").humanize
291
+ end
292
+ end
293
+
256
294
  def hrl_pagination
257
295
  render "hr_lite/shared/pagination"
258
296
  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
 
@@ -0,0 +1,42 @@
1
+ module HrLite
2
+ # Sends one queued hiring email through the host's mail adapter. The first
3
+ # step claims the row, so a redelivered or parallel job finds it taken and
4
+ # stops: a send is attempted at most once. A crash after the claim leaves
5
+ # "sending", which the screens show as "check Gmail", never as "send again".
6
+ # Adapter errors must not quote the recipient: their text is stored.
7
+ class HiringSendJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform(message_id)
11
+ return unless HiringMessage.where(id: message_id, status: "queued").update_all(status: "sending", updated_at: Time.current) == 1
12
+
13
+ message = HiringMessage.find(message_id)
14
+ HrLite::Current.set(actor: message.sent_by) { deliver(message) }
15
+ end
16
+
17
+ private
18
+
19
+ def deliver(message)
20
+ candidate = message.candidate
21
+ adapter = HrLite.config.hiring
22
+ sender = adapter&.sender
23
+ return finish(message, "failed", error: "No sender mailbox is connected") unless sender&.dig(:ready)
24
+
25
+ mail = HiringMerge.new(candidate, sender_name: sender[:name]).render(message.template)
26
+ return finish(message, "failed", error: "Missing #{mail.missing.join(', ')}") if mail.missing.any?
27
+
28
+ ids = adapter.send_email(to: candidate.email, subject: mail.subject, body: mail.body)
29
+ finish(message, "sent", sent_at: Time.current, gmail_message_id: ids[:message_id], gmail_thread_id: ids[:thread_id])
30
+ candidate.move_to!("invited") if candidate.stage == "imported"
31
+ rescue HrLite::HiringDeliveryUnknown => e
32
+ finish(message, "unknown", error: e.message.to_s.truncate(200))
33
+ rescue HrLite::HiringDeliveryFailed => e
34
+ finish(message, "failed", error: e.message.to_s.truncate(200))
35
+ end
36
+
37
+ def finish(message, status, **attributes)
38
+ message.update!(status: status, **attributes)
39
+ message.candidate.log!("email_#{status}", template: message.template.name)
40
+ end
41
+ end
42
+ end
@@ -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
@@ -0,0 +1,21 @@
1
+ module HrLite
2
+ # One hiring drive for one opening. Its fields fill the @placeholders in
3
+ # every email, so the offer lives here, never in template copy.
4
+ class HiringCampaign < ApplicationRecord
5
+ has_many :candidates, class_name: "HrLite::HiringCandidate", foreign_key: :campaign_id,
6
+ inverse_of: :campaign, dependent: :restrict_with_error
7
+
8
+ validates :title, :position, presence: true
9
+ validates :status, inclusion: { in: %w[open closed] }
10
+
11
+ scope :newest_first, -> { order(created_at: :desc) }
12
+
13
+ # The pipeline list and "select all" on it are the same filter.
14
+ def pipeline(stage: nil, campus: nil)
15
+ scope = candidates
16
+ scope = scope.where(stage: stage) if stage.presence_in(HiringCandidate::STAGES)
17
+ scope = scope.where(campus: campus) if campus.present?
18
+ scope.order(:name, :id)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,52 @@
1
+ module HrLite
2
+ # A person from a university's sheet, moving through one campaign. Every
3
+ # change goes on their timeline (AuditLog rows whose subject is them).
4
+ class HiringCandidate < ApplicationRecord
5
+ STAGES = %w[imported invited interested not_interested interview selected rejected on_hold].freeze
6
+ # Where a stage may go next. Sending moves imported → invited; scheduling moves to interview.
7
+ MOVES = {
8
+ "imported" => %w[invited interested not_interested],
9
+ "invited" => %w[interested not_interested],
10
+ "interested" => %w[interview not_interested on_hold rejected],
11
+ "not_interested" => %w[interested],
12
+ "interview" => %w[interview selected rejected on_hold],
13
+ "on_hold" => %w[interview selected rejected],
14
+ "selected" => [], "rejected" => []
15
+ }.freeze
16
+ REJECTION_REASONS = [ "Communication skills", "Not available for 6 months", "Location / relocation",
17
+ "Stipend not acceptable", "Did not attend interview", "Other" ].freeze
18
+
19
+ belongs_to :campaign, class_name: "HrLite::HiringCampaign", inverse_of: :candidates
20
+ has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :candidate_id, dependent: :restrict_with_error
21
+ has_many :interviews, class_name: "HrLite::HiringInterview", foreign_key: :candidate_id, dependent: :restrict_with_error
22
+
23
+ encrypts :phone, :date_of_birth
24
+ normalizes :email, with: ->(email) { email.to_s.strip.downcase }
25
+
26
+ validates :name, presence: true
27
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: { scope: :campaign_id }
28
+ validates :stage, inclusion: { in: STAGES }
29
+
30
+ def first_name = name.to_s.split.first
31
+ def stage_label = stage == "interview" ? "Interview · round #{round}" : stage.humanize
32
+ def timeline = AuditLog.where(subject_type: self.class.name, subject_id: id).includes(:actor).order(created_at: :desc, id: :desc)
33
+
34
+ # The one door for a stage change: refuses a move the pipeline does not
35
+ # allow, needs a listed reason to reject, and writes the timeline row.
36
+ def move_to!(to, reason: nil, note: nil, round: self.round)
37
+ raise ArgumentError, "#{stage_label} cannot move to #{to.humanize}." unless MOVES.fetch(stage).include?(to)
38
+ raise ArgumentError, "Pick a rejection reason." if to == "rejected" && !REJECTION_REASONS.include?(reason)
39
+ raise ArgumentError, "Write a note for “Other”." if reason == "Other" && note.blank?
40
+
41
+ transaction do
42
+ from = stage
43
+ update!(stage: to, round: round, rejection_reason: (to == "rejected" ? [ reason, note.presence ].compact.join(": ") : rejection_reason))
44
+ log!("stage", from: from, to: to, reason: (reason if to == "rejected"))
45
+ end
46
+ end
47
+
48
+ def log!(action, actor: HrLite::Current.actor, **changes)
49
+ AuditLog.record!(action: "hiring.#{action}", subject: self, actor: actor, changes: changes.compact)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ module HrLite
2
+ # One interview round on the sender's Google Calendar. A new round is a new
3
+ # row, so earlier rounds keep their own history.
4
+ class HiringInterview < ApplicationRecord
5
+ STATUSES = %w[unknown scheduled failed cancelled].freeze
6
+
7
+ belongs_to :candidate, class_name: "HrLite::HiringCandidate"
8
+
9
+ validates :status, inclusion: { in: STATUSES }
10
+ validates :round, numericality: { greater_than: 0 }
11
+ validates :starts_at, :ends_at, presence: true
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ module HrLite
2
+ # One template sent (or being sent) to one candidate. The unique index on
3
+ # candidate + template is what makes a repeated click or job a no-op.
4
+ # "sending" left behind by a crash, and "unknown", both mean: look in Gmail.
5
+ class HiringMessage < ApplicationRecord
6
+ STATUSES = %w[queued sending sent failed unknown].freeze
7
+
8
+ belongs_to :candidate, class_name: "HrLite::HiringCandidate"
9
+ belongs_to :template, class_name: "HrLite::HiringTemplate"
10
+ belongs_to :sent_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :status, inclusion: { in: STATUSES }
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ module HrLite
2
+ # An email HR writes once and sends to many. @placeholders are filled per
3
+ # candidate by HiringMerge at send time.
4
+ class HiringTemplate < ApplicationRecord
5
+ has_many :messages, class_name: "HrLite::HiringMessage", foreign_key: :template_id, dependent: :restrict_with_error
6
+
7
+ validates :name, :subject, :body, presence: true
8
+
9
+ scope :alphabetical, -> { order(:name) }
10
+ end
11
+ 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
@@ -0,0 +1,106 @@
1
+ module HrLite
2
+ # Reads a university's candidate sheet into a preview, then imports it.
3
+ # Headers match an alias list after case-folding and dropping punctuation
4
+ # and spaces. A column nobody recognises is kept in `extra`, never dropped;
5
+ # two columns claiming one field wait for HR to pick one.
6
+ class HiringImport
7
+ ALIASES = {
8
+ "name" => %w[studentname name candidatename fullname],
9
+ "email" => %w[emailid email emailaddress mailid],
10
+ "phone" => %w[contactno contactnumber phone phoneno mobile mobileno],
11
+ "date_of_birth" => %w[dob dobddmmyyyy dateofbirth],
12
+ "campus" => %w[campus college],
13
+ "course" => %w[coursepursuing course programme program],
14
+ "branch" => %w[branchspecialization branch specialization],
15
+ "cv_url" => %w[linkforcv cv cvlink resume resumelink]
16
+ }.freeze
17
+
18
+ Column = Data.define(:index, :header, :field, :extra_key)
19
+ Row = Data.define(:number, :attributes, :extra, :outcome, :reason)
20
+
21
+ def self.normalize(header) = header.to_s.downcase.gsub(/[^a-z0-9]/, "")
22
+
23
+ # Cell values arrive as the sheet stored them: never evaluated, never fetched.
24
+ def self.text(value)
25
+ case value
26
+ when nil then ""
27
+ when Float then value == value.floor ? value.to_i.to_s : value.to_s
28
+ when Date, Time then value.strftime("%d/%m/%Y")
29
+ else value.to_s.strip
30
+ end
31
+ end
32
+
33
+ attr_reader :columns, :conflicts, :rows
34
+
35
+ # rows: arrays of cell values, headers first. picks: { "phone" => "3" }
36
+ # names the column that wins a field two columns claim.
37
+ def initialize(campaign, rows, picks: {})
38
+ @campaign = campaign
39
+ header, *body = rows
40
+ map_columns(Array(header), picks.to_h.transform_values(&:to_i))
41
+ @rows = classify(body)
42
+ end
43
+
44
+ def counts = %w[create update skip].index_with { |outcome| rows.count { |row| row.outcome == outcome } }
45
+
46
+ def import!
47
+ return false if conflicts.any?
48
+
49
+ HiringCandidate.transaction do
50
+ rows.reject { |row| row.outcome == "skip" }.each do |row|
51
+ candidate = @campaign.candidates.find_or_initialize_by(email: row.attributes["email"])
52
+ created = candidate.new_record?
53
+ candidate.assign_attributes(row.attributes.merge("extra" => candidate.extra.to_h.merge(row.extra)))
54
+ candidate.save!
55
+ candidate.log!(created ? "imported" : "reimported") if created || candidate.saved_changes.any?
56
+ end
57
+ end
58
+ true
59
+ end
60
+
61
+ private
62
+
63
+ def map_columns(header, picks)
64
+ @columns = header.each_with_index.map do |cell, index|
65
+ field = ALIASES.find { |_, names| names.include?(self.class.normalize(cell)) }&.first
66
+ Column.new(index:, header: cell.to_s.strip, field:, extra_key: nil)
67
+ end
68
+ claimed = @columns.select(&:field).group_by(&:field)
69
+ @conflicts = claimed.select { |field, cols| cols.size > 1 && cols.none? { |c| c.index == picks[field] } }
70
+ # A column that lost its field (or is unknown) keeps its data in `extra`.
71
+ taken = HiringMerge::RESERVED + ALIASES.keys
72
+ @columns = @columns.map do |col|
73
+ loses = col.field && claimed[col.field].size > 1 && picks[col.field] != col.index
74
+ next col if col.field && !loses
75
+
76
+ key = col.header.parameterize(separator: "_").presence || "column_#{col.index + 1}"
77
+ key = "sheet_#{key}" if taken.include?(key) || !key.match?(/\A[a-z]/) # @placeholders start with a letter
78
+ taken << key
79
+ col.with(field: nil, extra_key: key)
80
+ end
81
+ end
82
+
83
+ def classify(body)
84
+ seen = {}
85
+ existing = @campaign.candidates.pluck(:email).to_set
86
+ body.each_with_index.filter_map do |cells, i|
87
+ number = i + 2 # sheet row, counting the header
88
+ values = @columns.to_h { |col| [ col, self.class.text(Array(cells)[col.index]) ] }
89
+ next if values.values.all?(&:blank?) # an empty row in the sheet is not a candidate
90
+
91
+ attributes = values.select { |col, _| col.field }.to_h { |col, v| [ col.field, v.presence ] }
92
+ attributes["email"] = attributes["email"].to_s.strip.downcase.presence
93
+ extra = values.select { |col, v| col.extra_key && v.present? }.to_h { |col, v| [ col.extra_key, v ] }
94
+ outcome, reason =
95
+ if attributes["name"].blank? then [ "skip", "no name" ]
96
+ elsif attributes["email"].blank? then [ "skip", "no email address" ]
97
+ elsif !attributes["email"].match?(URI::MailTo::EMAIL_REGEXP) then [ "skip", "email address looks wrong" ]
98
+ elsif seen[attributes["email"]] then [ "skip", "same email as row #{seen[attributes['email']]}" ]
99
+ else [ existing.include?(attributes["email"]) ? "update" : "create", nil ]
100
+ end
101
+ seen[attributes["email"]] ||= number if attributes["email"]
102
+ Row.new(number:, attributes:, extra:, outcome:, reason:)
103
+ end
104
+ end
105
+ end
106
+ end