hr_lite 0.60.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +14 -0
  4. data/app/assets/stylesheets/hr_lite/hr_lite.css +20 -0
  5. data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +88 -0
  6. data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +27 -0
  7. data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
  8. data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
  9. data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +45 -0
  10. data/app/helpers/hr_lite/application_helper.rb +38 -0
  11. data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
  12. data/app/models/hr_lite/hiring_campaign.rb +21 -0
  13. data/app/models/hr_lite/hiring_candidate.rb +52 -0
  14. data/app/models/hr_lite/hiring_interview.rb +13 -0
  15. data/app/models/hr_lite/hiring_message.rb +14 -0
  16. data/app/models/hr_lite/hiring_template.rb +11 -0
  17. data/app/services/hr_lite/hiring_import.rb +106 -0
  18. data/app/services/hr_lite/hiring_merge.rb +48 -0
  19. data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +18 -0
  20. data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
  21. data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
  22. data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
  23. data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
  24. data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
  25. data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +98 -0
  26. data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +145 -0
  27. data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
  28. data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +16 -0
  29. data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +40 -0
  30. data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
  31. data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
  32. data/config/routes.rb +10 -0
  33. data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
  34. data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
  35. data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
  36. data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
  37. data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
  38. data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
  39. data/lib/hr_lite/configuration.rb +14 -1
  40. data/lib/hr_lite/permissions.rb +2 -1
  41. data/lib/hr_lite/role_seeds.rb +2 -2
  42. data/lib/hr_lite/version.rb +1 -1
  43. data/lib/hr_lite.rb +6 -0
  44. metadata +33 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 051c3d4efb869bb1c40f819e884e245c5d45462c2664a62464c84ec2686b750e
4
- data.tar.gz: 263dbc61a8861a69b06b0b6597542e496feac6fd3bb6eb78cadd04a947681f41
3
+ metadata.gz: d57f2981c5062477e62d5cf7518fb074dfedccd73de6338d3263ce57143dd824
4
+ data.tar.gz: eb27ceb943445189d60db425d2fd7250d89fbb6e118f48b456d0a33eb22dfc4b
5
5
  SHA512:
6
- metadata.gz: c4b151e5a71926157f4976df9ad3fd55f3b60468c6f0d10f7877d2fc9c2363e1ebe7d5d9a86961660848744a840e9b96f597cc1207d86168f20dd7410096ba60
7
- data.tar.gz: c457f267dc96c0b89278bf870f431174204cb258879cb68e90b152ef37d224354068732b2b118678e6b1329cf24351c5a26dca8672e120549d83c3b906e622a1
6
+ metadata.gz: 128a548490e3eb5c3028c197eb352e14419d200bbff36c79c7237bb4f355b666dd525ad7a0e54a7d1c8f6814d3b8165e3311215ab8f17dd81ac20460ae47b926
7
+ data.tar.gz: 84179555ec4cf9ea6623071e0f72da671ff3f17f69f9d4771fbe6a833e6ba77ca6edcab85bbeebbf835d6aee2d2a23ee1f61d1cba42f7f26186e6d61d246ca14
data/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.61.0] - 2026-10-21
11
+
12
+ Hiring panel. Six migrations: five new tables and one that grants `hiring.manage` to HR, Leadership and Super Admin and seeds the first outreach email.
13
+
14
+ - **Campaigns.** One per opening. Position, programme, stipend, CTC, location and schedule live on the campaign and fill the email's @placeholders.
15
+ - **Sheet import with a preview.** Headers are matched through an alias list after case-folding and dropping punctuation, so each university's spelling works. Unknown columns are kept as extra data and usable as @placeholders. Two columns claiming one field wait for HR to pick one. Every skipped row says why, a repeat upload updates instead of duplicating, and nothing is saved before confirm. Phone and date of birth are encrypted. `config.read_sheet` reads the file (default: .csv only).
16
+ - **Bulk email from one mailbox.** A confirm page names everyone left out (already sent, or a placeholder empty or unknown). One spaced job per recipient goes through `config.hiring.send_email`, and a unique index plus a claimed row stop double sends. An answer that never came is marked for a person to check, never resent. Sends are limited to ten an hour per person.
17
+ - **Pipeline and timeline.** The stages are imported, invited, interested, not interested, interview (round N), selected, rejected and on hold. Moves the pipeline does not allow are refused, a rejection needs a listed reason, and every change goes on the candidate's timeline with who made it.
18
+ - **Interview invites.** Rounds go on the hiring mailbox's Google Calendar through `config.hiring`, with reschedule and cancel. Each round is its own row.
19
+
10
20
  ## [0.60.0] - 2026-10-21
11
21
 
12
22
  Background failures reach a person. No migrations.
data/README.md CHANGED
@@ -376,6 +376,20 @@ permission reaches (`leave.view`, `payroll.view`, `expense.approve`,
376
376
  Leadership picks Employee of the Month, Quarter and Year at `/admin/awards`,
377
377
  and Home shows the current winners to everyone.
378
378
 
379
+ ## Hiring
380
+
381
+ Admin › Hiring (`hiring.manage`): campaigns, candidate sheet import, bulk email, interview rounds and decisions. The engine never talks to Google itself. Set `config.hiring` to an object with:
382
+
383
+ ```ruby
384
+ sender # => { email:, name:, ready:, connect_url: }
385
+ send_email(to:, subject:, body:) # => { message_id:, thread_id: } (plain text)
386
+ create_event(summary:, description:, attendee:, starts_at:, ends_at:) # => { event_id:, meet_link: }
387
+ update_event(event_id:, starts_at:, ends_at:)
388
+ cancel_event(event_id:)
389
+ ```
390
+
391
+ Raise `HrLite::HiringDeliveryFailed` when nothing was sent and `HrLite::HiringDeliveryUnknown` when the provider may have acted (timeouts, 5xx). Never put the recipient in the error text: it is stored. `config.read_sheet = ->(path, filename) { rows }` reads uploads (default: .csv).
392
+
379
393
  ## Recurring jobs
380
394
 
381
395
  Schedule these on the host's job scheduler (cron, GoodJob, Sidekiq-cron,
@@ -653,3 +653,23 @@ a.hrl-tree__name { display: inline-flex; align-items: center; min-height: var(--
653
653
  .hrl-search__close { display: flex; justify-content: flex-end; margin: 0 0 var(--hrl-s-2); }
654
654
  .hrl-search::backdrop { background: rgb(15 23 42 / .4); }
655
655
  .hrl-search__list { list-style: none; margin: var(--hrl-s-1) 0 0; padding: 0; display: grid; gap: var(--hrl-s-2); }
656
+ /* Hiring email preview: plain text, so its line breaks are the email's. */
657
+ .hrl-hiring-preview { white-space: pre-line; overflow-wrap: anywhere; padding: var(--hrl-s-4); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm); background: var(--hrl-bg); }
658
+ /* Hiring candidate: actions first on a phone, a right-hand column on desktop. */
659
+ @media (min-width: 1024px) {
660
+ .hrl-grid--side { grid-template-columns: 2fr 1fr; align-items: start; }
661
+ .hrl-grid--side > .hrl-grid__aside { grid-column: 2; grid-row: 1 / span 9; }
662
+ }
663
+ .hrl-card--next { background: var(--hrl-info-bg); border-color: transparent; }
664
+ .hrl-hiring-decide > .button_to { flex: 1 1 8rem; }
665
+ .hrl-hiring-decide > .button_to .hrl-btn { width: 100%; }
666
+ .hrl-hiring-person { display: flex; align-items: center; gap: var(--hrl-s-3); width: 100%; }
667
+ .hrl-hiring-avatar { flex: none; width: 52px; height: 52px; border-radius: var(--hrl-radius-pill); background: var(--hrl-info-bg); color: var(--hrl-accent); display: grid; place-items: center; font: 700 var(--hrl-t-lead) var(--hrl-font-head); }
668
+ /* Pipeline stepper: a number until reached, a tick once passed. */
669
+ .hrl-steps { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--hrl-s-1); text-align: center; }
670
+ .hrl-steps li { display: grid; justify-items: center; gap: var(--hrl-s-1); font-size: var(--hrl-t-micro); color: var(--hrl-ink-2); }
671
+ .hrl-steps__dot { width: 28px; height: 28px; border-radius: var(--hrl-radius-pill); display: grid; place-items: center; font-weight: 700; border: 1px solid var(--hrl-line); background: var(--hrl-card); color: var(--hrl-muted); }
672
+ .hrl-steps .is-done .hrl-steps__dot { background: var(--hrl-ok); border-color: var(--hrl-ok); color: var(--hrl-accent-ink); }
673
+ .hrl-steps .is-now .hrl-steps__dot { background: var(--hrl-accent); border-color: var(--hrl-accent); color: var(--hrl-accent-ink); }
674
+ .hrl-steps .is-now { font-weight: 700; color: var(--hrl-accent); }
675
+ .hrl-mail__head { display: grid; gap: var(--hrl-s-2); padding-bottom: var(--hrl-s-3); margin-bottom: var(--hrl-s-3); border-bottom: 1px solid var(--hrl-line); overflow-wrap: anywhere; }
@@ -0,0 +1,88 @@
1
+ module HrLite
2
+ module Admin
3
+ # Hiring drives: the list, one campaign's pipeline, and its sheet import.
4
+ class HiringCampaignsController < ApplicationController
5
+ FIELDS = %i[title position programme stipend ctc location schedule status].freeze
6
+
7
+ before_action { hr_require_permission!("hiring.manage", scope: :all) }
8
+ before_action :set_campaign, except: %i[index new create]
9
+
10
+ def index
11
+ @campaigns = paginate(HiringCampaign.newest_first)
12
+ @counts = HiringCandidate.where(campaign_id: @campaigns.map(&:id)).group(:campaign_id, :stage).count
13
+ end
14
+
15
+ def show
16
+ @stage = params[:stage].presence_in(HiringCandidate::STAGES)
17
+ @campus = params[:campus].presence
18
+ @stage_counts = @campaign.candidates.group(:stage).count
19
+ @campuses = @campaign.candidates.where.not(campus: [ nil, "" ]).distinct.order(:campus).pluck(:campus)
20
+ @candidates = paginate(@campaign.pipeline(stage: @stage, campus: @campus))
21
+ @templates = HiringTemplate.alphabetical
22
+ @attention = HiringMessage.joins(:candidate).where(hr_lite_hiring_candidates: { campaign_id: @campaign.id })
23
+ .where(status: %w[failed unknown sending]).includes(:candidate, :template).limit(50)
24
+ end
25
+
26
+ # A new campaign starts from the last one's offer: HR edits what changed.
27
+ def new
28
+ @campaign = HiringCampaign.new(HiringCampaign.newest_first.first&.slice(*FIELDS.excluding(:title, :status)))
29
+ end
30
+
31
+ def create
32
+ @campaign = HiringCampaign.new(campaign_params)
33
+ return render(:new, status: :unprocessable_entity) unless @campaign.save
34
+
35
+ redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "Campaign created. Upload the candidate sheet next."
36
+ end
37
+
38
+ def edit; end
39
+
40
+ def update
41
+ return render(:edit, status: :unprocessable_entity) unless @campaign.update(campaign_params)
42
+
43
+ redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "Saved."
44
+ end
45
+
46
+ # GET: the upload form. POST with a file: the preview. POST with the
47
+ # sealed rows and confirm: the import. Nothing is saved before confirm.
48
+ def import
49
+ return if request.get?
50
+
51
+ rows = params[:sheet].present? ? read_sheet(params[:sheet]) : sealer.decrypt_and_verify(params[:rows].to_s)
52
+ @import = HiringImport.new(@campaign, rows, picks: params.fetch(:picks, {}).permit!.to_h)
53
+ if params[:confirm].present? && @import.import!
54
+ counts = @import.counts
55
+ return redirect_to hr_lite.admin_hiring_campaign_path(@campaign),
56
+ notice: "#{counts['create']} added, #{counts['update']} updated, #{counts['skip']} skipped."
57
+ end
58
+ @sealed = sealer.encrypt_and_sign(rows, expires_in: 1.hour)
59
+ render :preview
60
+ rescue ArgumentError => e
61
+ redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: e.message
62
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
63
+ redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: "That preview expired. Upload the sheet again."
64
+ rescue ActiveRecord::RecordNotUnique
65
+ redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: "Another import ran at the same time. Upload the sheet again."
66
+ end
67
+
68
+ private
69
+
70
+ def set_campaign = @campaign = HiringCampaign.find(params[:id])
71
+ def campaign_params = params.require(:hiring_campaign).permit(*FIELDS)
72
+
73
+ def read_sheet(file)
74
+ rows = HrLite.config.read_sheet.call(file.tempfile.path, file.original_filename)
75
+ Array(rows).map { |row| Array(row).map { |cell| HiringImport.text(cell) } }
76
+ rescue ArgumentError
77
+ raise
78
+ rescue StandardError
79
+ raise ArgumentError, "That file could not be read. Save it as .xlsx or .csv with the headers in the first row."
80
+ end
81
+
82
+ # The previewed rows ride in the form, encrypted, so nothing touches disk.
83
+ def sealer
84
+ ActiveSupport::MessageEncryptor.new(Rails.application.key_generator.generate_key("hr_lite.hiring_import", 32))
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,27 @@
1
+ module HrLite
2
+ module Admin
3
+ # One candidate: what we know, every email and interview, the timeline,
4
+ # and the decisions HR makes by hand.
5
+ class HiringCandidatesController < ApplicationController
6
+ # Invited and interview come from sending and scheduling, never a button.
7
+ MANUAL = %w[interested not_interested selected rejected on_hold].freeze
8
+
9
+ before_action { hr_require_permission!("hiring.manage", scope: :all) }
10
+ before_action { @candidate = HiringCandidate.includes(:campaign).find(params[:id]) }
11
+
12
+ def show
13
+ @messages = @candidate.messages.includes(:template).order(:created_at)
14
+ @interviews = @candidate.interviews.order(:round, :created_at)
15
+ @timeline = @candidate.timeline.limit(100)
16
+ end
17
+
18
+ def stage
19
+ to = params[:to].presence_in(MANUAL) or raise ArgumentError, "That is not a decision HR makes here."
20
+ @candidate.move_to!(to, reason: params[:reason], note: params[:note])
21
+ redirect_to hr_lite.admin_hiring_candidate_path(@candidate), notice: "Moved to #{@candidate.stage_label}."
22
+ rescue ArgumentError => e
23
+ redirect_to hr_lite.admin_hiring_candidate_path(@candidate), alert: e.message
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,77 @@
1
+ module HrLite
2
+ module Admin
3
+ # Interview rounds on the hiring mailbox's Google Calendar. The row is
4
+ # written before Google is called, so an answer that never arrives still
5
+ # leaves a record ("unknown") for HR to check.
6
+ class HiringInterviewsController < ApplicationController
7
+ LENGTHS = [ 30, 45, 60 ].freeze
8
+
9
+ before_action { hr_require_permission!("hiring.manage", scope: :all) }
10
+ before_action { @candidate = HiringCandidate.includes(:campaign).find(params[:hiring_candidate_id]) }
11
+ before_action(except: :create) { @interview = @candidate.interviews.find(params[:id]) }
12
+ before_action :require_calendar!
13
+
14
+ def create
15
+ return back(alert: "#{@candidate.stage_label} cannot be interviewed.") unless HiringCandidate::MOVES.fetch(@candidate.stage).include?("interview")
16
+ return back(alert: "Pick a date and time in the future.") unless (starts, ends = slot)
17
+
18
+ round = @candidate.interviews.where(status: %w[scheduled unknown]).maximum(:round).to_i + 1
19
+ @interview = @candidate.interviews.create!(round: round, starts_at: starts, ends_at: ends)
20
+ ids = adapter.create_event(summary: "Interview: #{@candidate.campaign.position} (round #{round})",
21
+ description: "#{@candidate.campaign.title}. Join with the Google Meet link in this invite.",
22
+ attendee: @candidate.email, starts_at: starts, ends_at: ends)
23
+ @interview.update!(status: "scheduled", event_id: ids[:event_id], meet_link: ids[:meet_link])
24
+ @candidate.move_to!("interview", round: round)
25
+ @candidate.log!("interview_scheduled", round: round, starts_at: starts.iso8601)
26
+ back(notice: "Invite sent for round #{round}.")
27
+ rescue HrLite::HiringDeliveryUnknown, HrLite::HiringDeliveryFailed => e
28
+ google_trouble(e, "interview_scheduled")
29
+ end
30
+
31
+ def update
32
+ return back(alert: "Pick a date and time in the future.") unless (starts, ends = slot)
33
+ adapter.update_event(event_id: @interview.event_id, starts_at: starts, ends_at: ends)
34
+ @interview.update!(starts_at: starts, ends_at: ends)
35
+ @candidate.log!("interview_rescheduled", round: @interview.round, starts_at: starts.iso8601)
36
+ back(notice: "Round #{@interview.round} moved. Google told the candidate.")
37
+ rescue HrLite::HiringDeliveryUnknown, HrLite::HiringDeliveryFailed => e
38
+ google_trouble(e, "interview_rescheduled")
39
+ end
40
+
41
+ def destroy
42
+ adapter.cancel_event(event_id: @interview.event_id) if @interview.event_id
43
+ @interview.update!(status: "cancelled")
44
+ @candidate.log!("interview_cancelled", round: @interview.round)
45
+ back(notice: "Round #{@interview.round} cancelled. Google told the candidate.")
46
+ rescue HrLite::HiringDeliveryUnknown, HrLite::HiringDeliveryFailed => e
47
+ google_trouble(e, "interview_cancelled")
48
+ end
49
+
50
+ private
51
+
52
+ def adapter = HrLite.config.hiring
53
+ def back(**flash) = redirect_to(hr_lite.admin_hiring_candidate_path(@candidate), **flash)
54
+
55
+ def require_calendar!
56
+ back(alert: "Connect the hiring mailbox before scheduling.") unless adapter&.sender&.dig(:ready)
57
+ end
58
+
59
+ def slot
60
+ starts = Time.zone.parse("#{params[:date]} #{params[:time]}")
61
+ return unless starts&.future?
62
+
63
+ minutes = LENGTHS.include?(params[:minutes].to_i) ? params[:minutes].to_i : LENGTHS.first
64
+ [ starts, starts + minutes.minutes ]
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+
69
+ def google_trouble(error, action)
70
+ unknown = error.is_a?(HrLite::HiringDeliveryUnknown)
71
+ @interview.update!(status: unknown ? "unknown" : "failed", error: error.message.to_s.truncate(200)) if action == "interview_scheduled"
72
+ @candidate.log!("#{action}_#{unknown ? 'unknown' : 'failed'}", round: @interview.round)
73
+ back(alert: unknown ? "Google did not confirm. Check the hiring calendar before trying again." : "Google refused: #{error.message}")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,62 @@
1
+ module HrLite
2
+ module Admin
3
+ # Bulk email: a confirm page that says exactly who gets it and who is
4
+ # left out (and why), then one queued job per recipient, spaced out.
5
+ class HiringSendsController < ApplicationController
6
+ SPACING = 3.seconds
7
+
8
+ before_action { hr_require_permission!("hiring.manage", scope: :all) }
9
+ rate_limit to: 10, within: 1.hour, only: :create, name: "hiring_send", by: -> { hr_current_user.id },
10
+ with: -> { redirect_to hr_lite.admin_hiring_campaigns_path, alert: "Ten sends in an hour is the limit. Try again later." }
11
+
12
+ def new
13
+ load_recipients
14
+ end
15
+
16
+ def create
17
+ load_recipients
18
+ unless @sender&.dig(:ready)
19
+ return redirect_to hr_lite.admin_hiring_campaign_path(@campaign), alert: "Connect the hiring mailbox before sending."
20
+ end
21
+
22
+ batch = SecureRandom.uuid
23
+ queued = @recipients.filter_map do |candidate|
24
+ HiringMessage.create!(candidate: candidate, template: @template, batch: batch, sent_by: hr_current_user)
25
+ rescue ActiveRecord::RecordNotUnique
26
+ nil # already queued by another click
27
+ end
28
+ queued.each_with_index { |message, i| HiringSendJob.set(wait: SPACING * i).perform_later(message.id) }
29
+ AuditLog.record!(action: "hiring.batch", subject: @campaign,
30
+ changes: { "template" => @template.name, "recipients" => queued.size, "batch" => batch })
31
+ redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "#{queued.size} emails queued from #{@sender[:email]}."
32
+ end
33
+
34
+ # Only a definite failure is sent again; "unknown" may already be in Gmail.
35
+ def retry
36
+ message = HiringMessage.find(params[:id])
37
+ if message.status == "failed"
38
+ message.update!(status: "queued", error: nil, sent_by: hr_current_user)
39
+ HiringSendJob.perform_later(message.id)
40
+ end
41
+ redirect_to hr_lite.admin_hiring_campaign_path(message.candidate.campaign_id), notice: "Queued again."
42
+ end
43
+
44
+ private
45
+
46
+ # Recomputed on every request from the ids: the confirm page is never trusted.
47
+ def load_recipients
48
+ @campaign = HiringCampaign.find(params[:hiring_campaign_id])
49
+ @template = HiringTemplate.find(params[:template_id])
50
+ @sender = HrLite.config.hiring&.sender
51
+ scope = params[:all].present? ? @campaign.pipeline(stage: params[:stage], campus: params[:campus]) : @campaign.pipeline.where(id: Array(params[:candidate_ids]))
52
+ already = HiringMessage.where(template: @template, candidate_id: scope.select(:id)).pluck(:candidate_id).to_set
53
+ @recipients, @left_out = [], []
54
+ scope.includes(:campaign, :interviews).each do |candidate|
55
+ missing = HiringMerge.new(candidate, sender_name: @sender&.dig(:name)).render(@template).missing
56
+ reason = already.include?(candidate.id) ? "already sent this template" : (missing.join(", ") if missing.any?)
57
+ reason ? @left_out << [ candidate, reason ] : @recipients << candidate
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,45 @@
1
+ module HrLite
2
+ module Admin
3
+ # Hiring emails HR writes once and sends to many, with a preview filled
4
+ # for a real candidate.
5
+ class HiringTemplatesController < ApplicationController
6
+ before_action { hr_require_permission!("hiring.manage", scope: :all) }
7
+ before_action(only: %i[edit update]) { @template = HiringTemplate.find(params[:id]) }
8
+
9
+ def index = @templates = HiringTemplate.alphabetical
10
+ def new = @template = HiringTemplate.new
11
+
12
+ def create
13
+ @template = HiringTemplate.new(template_params)
14
+ return render(:new, status: :unprocessable_entity) unless @template.save
15
+
16
+ redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
17
+ end
18
+
19
+ def edit = load_preview
20
+
21
+ def update
22
+ return load_preview && render(:edit, status: :unprocessable_entity) unless @template.update(template_params)
23
+
24
+ redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
25
+ end
26
+
27
+ private
28
+
29
+ def load_preview
30
+ @sample = HiringCandidate.includes(:campaign, :interviews).order(updated_at: :desc).limit(200).to_a
31
+ @preview_for = @sample.find { |c| c.id == params[:candidate_id].to_i } || @sample.first
32
+ return true unless @preview_for
33
+
34
+ sender_name = HrLite.config.hiring&.sender&.dig(:name)
35
+ @preview = HiringMerge.new(@preview_for, sender_name:).render(@template)
36
+ # Readiness across the previewed candidate's campaign: who a send would reach.
37
+ @campaign_size = @preview_for.campaign.candidates.count
38
+ @ready = @preview_for.campaign.candidates.includes(:campaign, :interviews).count { |c| HiringMerge.new(c, sender_name:).render(@template).missing.empty? }
39
+ true
40
+ end
41
+
42
+ def template_params = params.require(:hiring_template).permit(:name, :subject, :body)
43
+ end
44
+ end
45
+ 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
@@ -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
@@ -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