hr_lite 0.60.0 → 0.62.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +18 -0
- data/app/assets/javascripts/hr_lite/select_all.js +21 -0
- data/app/assets/stylesheets/hr_lite/hr_lite.css +26 -0
- data/app/controllers/hr_lite/admin/hiring_campaigns_controller.rb +96 -0
- data/app/controllers/hr_lite/admin/hiring_candidates_controller.rb +50 -0
- data/app/controllers/hr_lite/admin/hiring_interviews_controller.rb +77 -0
- data/app/controllers/hr_lite/admin/hiring_sends_controller.rb +62 -0
- data/app/controllers/hr_lite/admin/hiring_templates_controller.rb +71 -0
- data/app/controllers/hr_lite/hiring_replies_controller.rb +32 -0
- data/app/helpers/hr_lite/application_helper.rb +38 -0
- data/app/jobs/hr_lite/hiring_send_job.rb +42 -0
- data/app/models/hr_lite/hiring_campaign.rb +23 -0
- data/app/models/hr_lite/hiring_candidate.rb +56 -0
- data/app/models/hr_lite/hiring_interview.rb +13 -0
- data/app/models/hr_lite/hiring_message.rb +14 -0
- data/app/models/hr_lite/hiring_reply.rb +144 -0
- data/app/models/hr_lite/hiring_template.rb +11 -0
- data/app/services/hr_lite/hiring_import.rb +106 -0
- data/app/services/hr_lite/hiring_merge.rb +54 -0
- data/app/views/hr_lite/admin/hiring_campaigns/_form.html.erb +19 -0
- data/app/views/hr_lite/admin/hiring_campaigns/edit.html.erb +3 -0
- data/app/views/hr_lite/admin/hiring_campaigns/import.html.erb +18 -0
- data/app/views/hr_lite/admin/hiring_campaigns/index.html.erb +21 -0
- data/app/views/hr_lite/admin/hiring_campaigns/new.html.erb +3 -0
- data/app/views/hr_lite/admin/hiring_campaigns/preview.html.erb +62 -0
- data/app/views/hr_lite/admin/hiring_campaigns/show.html.erb +106 -0
- data/app/views/hr_lite/admin/hiring_candidates/show.html.erb +186 -0
- data/app/views/hr_lite/admin/hiring_sends/new.html.erb +34 -0
- data/app/views/hr_lite/admin/hiring_templates/_form.html.erb +20 -0
- data/app/views/hr_lite/admin/hiring_templates/edit.html.erb +43 -0
- data/app/views/hr_lite/admin/hiring_templates/index.html.erb +15 -0
- data/app/views/hr_lite/admin/hiring_templates/new.html.erb +3 -0
- data/app/views/hr_lite/hiring_replies/show.html.erb +105 -0
- data/app/views/layouts/hr_lite/application.html.erb +1 -0
- data/app/views/layouts/hr_lite/public.html.erb +21 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20261021100000_create_hr_lite_hiring_campaigns.rb +15 -0
- data/db/migrate/20261021100100_create_hr_lite_hiring_candidates.rb +22 -0
- data/db/migrate/20261021100200_create_hr_lite_hiring_templates.rb +10 -0
- data/db/migrate/20261021100300_create_hr_lite_hiring_messages.rb +19 -0
- data/db/migrate/20261021100400_create_hr_lite_hiring_interviews.rb +16 -0
- data/db/migrate/20261021100500_seed_hr_lite_hiring.rb +43 -0
- data/db/migrate/20261022090000_create_hr_lite_hiring_replies.rb +26 -0
- data/db/migrate/20261022090100_add_allow_sunday_to_hr_lite_hiring_campaigns.rb +6 -0
- data/lib/hr_lite/configuration.rb +14 -1
- data/lib/hr_lite/permissions.rb +2 -1
- data/lib/hr_lite/role_seeds.rb +2 -2
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +6 -0
- metadata +40 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f779cdbd416cb664617888bbb71b2c6866742b52e3ad5525ac2c5819a9b9908
|
|
4
|
+
data.tar.gz: 346563b5b820eabc5aec4d83076cb1a3dd37f6309c159ad15c7a3f70698032a3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 682e1b50a140c01d357a77666d721629e713dc8c1f97465ea8943c92b8d0cee5a5e47933c98f9451d5e80019bdc548879a27f1c72002279a19ab051500873ce6
|
|
7
|
+
data.tar.gz: 57fa8aa33593a9f4ac0d88abf82dcc47dd32f666872924038ffb1de4754cb981312fec76e23fcd9706bc31b2aa75a3dc40a42bd25b8d4c947d26705b2f356f2c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.62.0] - 2026-10-22
|
|
11
|
+
|
|
12
|
+
Hiring replies per round, an AI draft button and the sender's signature. Two migrations: a new `hr_lite_hiring_replies` table, and `allow_sunday` on campaigns.
|
|
13
|
+
|
|
14
|
+
- **Reply links per round.** `@round1_link`, `@round2_link` and `@round3_link` each open the candidate's own `/apply/:token` page for that round, with no login, one reply per link. Every round asks for one of the next ten interview days and one 2-hour slot (10–12, 12–2, 2–4, 4–6, 6–8; at most 4 candidates per slot per day), plus an optional alternate phone. Round 1 also asks for interest, joining time and four yes/no questions. Round 2 asks for a CV and photo ID. Round 3 collects the joining details: marksheets, an Aadhaar number and card, and a PAN number and card. A candidate without a PAN can tick "I don't have a PAN yet", and HR sees "PAN pending". Salary slips for the last 3 months are optional; a fresher ticks "I have no salary slips". A consent box is required. Documents must be PDF, JPG or PNG, checked from the file's bytes, at most 5 MB each. They are encrypted before they reach storage, so a leaked blob is unreadable, and HR downloads them only through an audit-logged route that decrypts them. Aadhaar and PAN numbers are encrypted. HR sees them masked (XXXX XXXX 1234, XXXXX1234X), and "Show full" is an audit-logged POST that shows the number once. Sunday is off unless the campaign ticks "Allow Sunday interviews". A reply moves round 1 candidates to interested or not interested, goes on the timeline, notifies the HR sender, and prefills "Schedule round N". The campaign page shows linked count tiles: invited, replied, interested, and booked for rounds 1, 2 and 3.
|
|
15
|
+
- **Write with AI** on the template form, when the host adds `config.hiring.draft_email(text:, placeholders:)`. It rewrites what HR typed into a template name (if empty), subject and message that use only known @fields, and saves nothing. Output with an unknown field is refused. It is limited to 20 drafts an hour per person and is audit-logged. It posts the same form with `ai=1`, because `formaction` would break per-form CSRF tokens.
|
|
16
|
+
- **Signature preview.** The template preview shows `config.hiring.signature` (sanitized) when the host provides it.
|
|
17
|
+
- **Select all ticks the rows.** "All N in this filter" now ticks and unticks every row, through `select_all.js`.
|
|
18
|
+
|
|
19
|
+
## [0.61.0] - 2026-10-21
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
- **Campaigns.** One per opening. Position, programme, stipend, CTC, location and schedule live on the campaign and fill the email's @placeholders.
|
|
24
|
+
- **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).
|
|
25
|
+
- **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.
|
|
26
|
+
- **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.
|
|
27
|
+
- **Interview invites.** Rounds go on the hiring mailbox's Google Calendar through `config.hiring`, with reschedule and cancel. Each round is its own row.
|
|
28
|
+
|
|
10
29
|
## [0.60.0] - 2026-10-21
|
|
11
30
|
|
|
12
31
|
Background failures reach a person. No migrations.
|
data/README.md
CHANGED
|
@@ -376,6 +376,24 @@ 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
|
+
draft_email(text:, placeholders:) # optional => { subject:, body: } for "Write with AI"
|
|
390
|
+
signature # optional => the sender's HTML signature, for the preview
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`@round1_link`, `@round2_link` and `@round3_link` point each candidate at their own `/apply/:token` page for that round, on `public_url_base`. The page needs no login. Round 1 asks for interest, joining time and four yes/no questions. Every round asks for a day, one 2-hour slot and an optional alternate phone. Rounds 2 and 3 ask for documents. Sunday is offered only when the campaign allows it.
|
|
394
|
+
|
|
395
|
+
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).
|
|
396
|
+
|
|
379
397
|
## Recurring jobs
|
|
380
398
|
|
|
381
399
|
Schedule these on the host's job scheduler (cron, GoodJob, Sidekiq-cron,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// "Select all" for a list of checkboxes in one form.
|
|
2
|
+
// Markup contract: <input type="checkbox" data-hrl-select-all="candidate_ids[]">.
|
|
3
|
+
// Ticking it ticks every box of that name in the same form; unticking any row
|
|
4
|
+
// unticks it again. The server still reads the box itself, so it works without JS.
|
|
5
|
+
(function () {
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
document.addEventListener("change", function (event) {
|
|
9
|
+
var box = event.target;
|
|
10
|
+
var form = box.form;
|
|
11
|
+
if (!form) return;
|
|
12
|
+
var all = form.querySelector("[data-hrl-select-all]");
|
|
13
|
+
if (!all) return;
|
|
14
|
+
var rows = form.querySelectorAll('input[name="' + all.getAttribute("data-hrl-select-all") + '"]');
|
|
15
|
+
if (box === all) {
|
|
16
|
+
rows.forEach(function (row) { row.checked = all.checked; });
|
|
17
|
+
} else if (box.name === all.getAttribute("data-hrl-select-all") && !box.checked) {
|
|
18
|
+
all.checked = false;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
})();
|
|
@@ -653,3 +653,29 @@ 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; }
|
|
676
|
+
/* The candidate reply page: no staff shell, one readable column. */
|
|
677
|
+
.hrl-public { max-width: 40rem; margin: 0 auto; padding: var(--hrl-s-6) var(--hrl-gutter) calc(var(--hrl-s-10) + env(safe-area-inset-bottom)); }
|
|
678
|
+
.hrl-hiring-signature { margin-top: var(--hrl-s-3); padding-top: var(--hrl-s-3); border-top: 1px dashed var(--hrl-line); font-size: var(--hrl-t-small); overflow-wrap: anywhere; }
|
|
679
|
+
.hrl-public__logo { display: block; height: 40px; width: auto; margin-bottom: var(--hrl-s-4); }
|
|
680
|
+
.hrl-kpi--link { display: block; min-height: var(--hrl-target); color: inherit; text-decoration: none; }
|
|
681
|
+
.hrl-kpi--link:hover { border-color: var(--hrl-accent); }
|
|
@@ -0,0 +1,96 @@
|
|
|
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 allow_sunday].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
|
+
@replied = params[:replied].presence
|
|
19
|
+
@round = params[:round].to_i.presence_in([ 1, 2, 3 ])
|
|
20
|
+
@stage_counts = @campaign.candidates.group(:stage).count
|
|
21
|
+
replies = HiringReply.responded.where(candidate_id: @campaign.candidates.select(:id))
|
|
22
|
+
# Each tile's count is exactly what its link lists.
|
|
23
|
+
@kpis = { "Awaiting reply" => [ @stage_counts["invited"].to_i, { stage: "invited" } ],
|
|
24
|
+
"Replied" => [ replies.distinct.count(:candidate_id), { replied: 1 } ],
|
|
25
|
+
"Interested" => [ @stage_counts["interested"].to_i, { stage: "interested" } ] }
|
|
26
|
+
(1..3).each { |r| @kpis["Booked round #{r}"] = [ replies.where(round: r).where.not(preferred_slot: nil).count, { round: r } ] }
|
|
27
|
+
@campuses = @campaign.candidates.where.not(campus: [ nil, "" ]).distinct.order(:campus).pluck(:campus)
|
|
28
|
+
@candidates = paginate(@campaign.pipeline(stage: @stage, campus: @campus, replied: @replied, round: @round))
|
|
29
|
+
@templates = HiringTemplate.alphabetical
|
|
30
|
+
@attention = HiringMessage.joins(:candidate).where(hr_lite_hiring_candidates: { campaign_id: @campaign.id })
|
|
31
|
+
.where(status: %w[failed unknown sending]).includes(:candidate, :template).limit(50)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A new campaign starts from the last one's offer: HR edits what changed.
|
|
35
|
+
def new
|
|
36
|
+
@campaign = HiringCampaign.new(HiringCampaign.newest_first.first&.slice(*FIELDS.excluding(:title, :status)))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def create
|
|
40
|
+
@campaign = HiringCampaign.new(campaign_params)
|
|
41
|
+
return render(:new, status: :unprocessable_entity) unless @campaign.save
|
|
42
|
+
|
|
43
|
+
redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "Campaign created. Upload the candidate sheet next."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def edit; end
|
|
47
|
+
|
|
48
|
+
def update
|
|
49
|
+
return render(:edit, status: :unprocessable_entity) unless @campaign.update(campaign_params)
|
|
50
|
+
|
|
51
|
+
redirect_to hr_lite.admin_hiring_campaign_path(@campaign), notice: "Saved."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# GET: the upload form. POST with a file: the preview. POST with the
|
|
55
|
+
# sealed rows and confirm: the import. Nothing is saved before confirm.
|
|
56
|
+
def import
|
|
57
|
+
return if request.get?
|
|
58
|
+
|
|
59
|
+
rows = params[:sheet].present? ? read_sheet(params[:sheet]) : sealer.decrypt_and_verify(params[:rows].to_s)
|
|
60
|
+
@import = HiringImport.new(@campaign, rows, picks: params.fetch(:picks, {}).permit!.to_h)
|
|
61
|
+
if params[:confirm].present? && @import.import!
|
|
62
|
+
counts = @import.counts
|
|
63
|
+
return redirect_to hr_lite.admin_hiring_campaign_path(@campaign),
|
|
64
|
+
notice: "#{counts['create']} added, #{counts['update']} updated, #{counts['skip']} skipped."
|
|
65
|
+
end
|
|
66
|
+
@sealed = sealer.encrypt_and_sign(rows, expires_in: 1.hour)
|
|
67
|
+
render :preview
|
|
68
|
+
rescue ArgumentError => e
|
|
69
|
+
redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: e.message
|
|
70
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage
|
|
71
|
+
redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: "That preview expired. Upload the sheet again."
|
|
72
|
+
rescue ActiveRecord::RecordNotUnique
|
|
73
|
+
redirect_to hr_lite.import_admin_hiring_campaign_path(@campaign), alert: "Another import ran at the same time. Upload the sheet again."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def set_campaign = @campaign = HiringCampaign.find(params[:id])
|
|
79
|
+
def campaign_params = params.require(:hiring_campaign).permit(*FIELDS)
|
|
80
|
+
|
|
81
|
+
def read_sheet(file)
|
|
82
|
+
rows = HrLite.config.read_sheet.call(file.tempfile.path, file.original_filename)
|
|
83
|
+
Array(rows).map { |row| Array(row).map { |cell| HiringImport.text(cell) } }
|
|
84
|
+
rescue ArgumentError
|
|
85
|
+
raise
|
|
86
|
+
rescue StandardError
|
|
87
|
+
raise ArgumentError, "That file could not be read. Save it as .xlsx or .csv with the headers in the first row."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The previewed rows ride in the form, encrypted, so nothing touches disk.
|
|
91
|
+
def sealer
|
|
92
|
+
ActiveSupport::MessageEncryptor.new(Rails.application.key_generator.generate_key("hr_lite.hiring_import", 32))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
@replies = @candidate.replies.responded.order(:round).with_attached_documents
|
|
17
|
+
next_round = [ @candidate.round, *@interviews.select { |i| %w[scheduled unknown].include?(i.status) }.map(&:round) ].max + 1
|
|
18
|
+
@next_reply = @replies.find { |r| r.round == next_round && r.preferred_slot }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A document from the candidate's reply, streamed only to hiring.manage and audit-logged.
|
|
22
|
+
def document
|
|
23
|
+
attachment = ActiveStorage::Attachment.find_by!(id: params[:attachment_id], record_type: "HrLite::HiringReply",
|
|
24
|
+
record_id: @candidate.replies.select(:id))
|
|
25
|
+
AuditLog.record!(action: "hiring.document_viewed", subject: @candidate, changes: { "attachment" => attachment.id })
|
|
26
|
+
bytes = attachment.record.read_document(attachment)
|
|
27
|
+
send_data bytes, filename: attachment.filename.to_s.split("--", 2).last, disposition: "attachment",
|
|
28
|
+
type: Marcel::MimeType.for(StringIO.new(bytes), name: attachment.filename.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The full Aadhaar or PAN, shown once on this render, audit-logged; never stored in the session.
|
|
32
|
+
def reveal
|
|
33
|
+
reply = @candidate.replies.find(params[:reply_id])
|
|
34
|
+
field = params[:field].presence_in(%w[aadhaar pan]) or raise ActiveRecord::RecordNotFound
|
|
35
|
+
AuditLog.record!(action: "hiring.kyc_revealed", subject: @candidate, changes: { "field" => field, "round" => reply.round })
|
|
36
|
+
@revealed = { reply.id => { field => (field == "aadhaar" ? reply.aadhaar_number : reply.pan_number) } }
|
|
37
|
+
show
|
|
38
|
+
render :show
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def stage
|
|
42
|
+
to = params[:to].presence_in(MANUAL) or raise ArgumentError, "That is not a decision HR makes here."
|
|
43
|
+
@candidate.move_to!(to, reason: params[:reason], note: params[:note])
|
|
44
|
+
redirect_to hr_lite.admin_hiring_candidate_path(@candidate), notice: "Moved to #{@candidate.stage_label}."
|
|
45
|
+
rescue ArgumentError => e
|
|
46
|
+
redirect_to hr_lite.admin_hiring_candidate_path(@candidate), alert: e.message
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
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], replied: params[:replied], round: params[:round].to_i.presence_in([ 1, 2, 3 ])) : @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,71 @@
|
|
|
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
|
+
# "Write with AI" posts the same form with ai=1 (formaction would break per-form CSRF tokens).
|
|
9
|
+
rate_limit to: 20, within: 1.hour, only: %i[create update], if: -> { params[:ai].present? }, name: "hiring_ai_draft", by: -> { hr_current_user.id },
|
|
10
|
+
with: -> { redirect_to hr_lite.admin_hiring_templates_path, alert: "Twenty AI drafts an hour is the limit. Try again later." }
|
|
11
|
+
|
|
12
|
+
def index = @templates = HiringTemplate.alphabetical
|
|
13
|
+
def new = @template = HiringTemplate.new
|
|
14
|
+
|
|
15
|
+
def create
|
|
16
|
+
@template = HiringTemplate.new(template_params)
|
|
17
|
+
return draft if params[:ai].present?
|
|
18
|
+
return render(:new, status: :unprocessable_entity) unless @template.save
|
|
19
|
+
|
|
20
|
+
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def edit = load_preview
|
|
24
|
+
|
|
25
|
+
def update
|
|
26
|
+
return draft if params[:ai].present?
|
|
27
|
+
return load_preview && render(:edit, status: :unprocessable_entity) unless @template.update(template_params)
|
|
28
|
+
|
|
29
|
+
redirect_to hr_lite.edit_admin_hiring_template_path(@template), notice: "Saved."
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# "Write with AI": rewrites what HR typed into a clean email that uses
|
|
35
|
+
# only known @fields. Nothing is saved; HR reviews it, then saves.
|
|
36
|
+
def draft
|
|
37
|
+
@template.assign_attributes(template_params)
|
|
38
|
+
apply_ai_draft
|
|
39
|
+
@template.persisted? ? load_preview && render(:edit) : render(:new)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def apply_ai_draft
|
|
43
|
+
out = HrLite.config.hiring.draft_email(text: "Subject: #{@template.subject}\n\n#{@template.body}", placeholders: HiringMerge::RESERVED)
|
|
44
|
+
unknown = HiringMerge.tokens("#{out[:subject]} #{out[:body]}") - HiringMerge::RESERVED
|
|
45
|
+
raise HrLite::HiringDeliveryFailed, "it used unknown fields (#{unknown.map { |t| "@#{t}" }.join(', ')})" if unknown.any?
|
|
46
|
+
|
|
47
|
+
@template.assign_attributes(subject: out[:subject], body: out[:body])
|
|
48
|
+
@template.name = out[:name] if @template.name.blank? && out[:name].present?
|
|
49
|
+
AuditLog.record!(action: "hiring.ai_draft", subject: @template)
|
|
50
|
+
flash.now[:notice] = "AI draft ready. Check it, then save."
|
|
51
|
+
rescue HrLite::HiringDeliveryFailed, HrLite::HiringDeliveryUnknown => e
|
|
52
|
+
flash.now[:alert] = "AI could not draft this: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def load_preview
|
|
56
|
+
@sample = HiringCandidate.includes(:campaign, :interviews).order(updated_at: :desc).limit(200).to_a
|
|
57
|
+
@preview_for = @sample.find { |c| c.id == params[:candidate_id].to_i } || @sample.first
|
|
58
|
+
return true unless @preview_for
|
|
59
|
+
|
|
60
|
+
sender_name = HrLite.config.hiring&.sender&.dig(:name)
|
|
61
|
+
@preview = HiringMerge.new(@preview_for, sender_name:).render(@template)
|
|
62
|
+
# Readiness across the previewed candidate's campaign: who a send would reach.
|
|
63
|
+
@campaign_size = @preview_for.campaign.candidates.count
|
|
64
|
+
@ready = @preview_for.campaign.candidates.includes(:campaign, :interviews).count { |c| HiringMerge.new(c, sender_name:).render(@template).missing.empty? }
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def template_params = params.require(:hiring_template).permit(:name, :subject, :body)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The candidate's own page for one interview round, from @round1_link …
|
|
3
|
+
# @round3_link: their day and slot, round 1's interest questions, and from
|
|
4
|
+
# round 2 their documents. One reply per link.
|
|
5
|
+
class HiringRepliesController < ApplicationController
|
|
6
|
+
# WHY no login: candidates have no account. The unguessable 24-character
|
|
7
|
+
# token in their link is the key, and it opens only that one reply.
|
|
8
|
+
skip_before_action :hr_authenticate!
|
|
9
|
+
skip_before_action HrLite.config.authenticate_method, raise: false
|
|
10
|
+
layout "hr_lite/public"
|
|
11
|
+
rate_limit to: 10, within: 1.hour, only: :create, name: "hiring_reply",
|
|
12
|
+
with: -> { redirect_to hr_lite.hiring_reply_path(params[:token]), alert: "Too many tries. Please try again in an hour." }
|
|
13
|
+
|
|
14
|
+
before_action do
|
|
15
|
+
@reply = HiringReply.includes(candidate: :campaign).find_by!(token: params[:token].to_s)
|
|
16
|
+
@candidate = @reply.candidate
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show; end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
@reply.submit!(interested: params[:interested].to_s, date: params[:date], slot: params[:slot], joining: params[:joining],
|
|
23
|
+
yes_no: params.permit(*HiringReply::YES_NO.keys).to_h, alternate_phone: params[:alternate_phone].to_s.strip,
|
|
24
|
+
files: params.fetch(:documents, {}).permit(*HiringReply::DOCUMENTS.fetch(@reply.round, {}).keys).to_h,
|
|
25
|
+
kyc: params.permit(:aadhaar_number, :pan_number, :pan_pending, :no_salary_slips, :consent).to_h.symbolize_keys)
|
|
26
|
+
redirect_to hr_lite.hiring_reply_path(@reply.token)
|
|
27
|
+
rescue ArgumentError => e
|
|
28
|
+
flash.now[:alert] = e.message
|
|
29
|
+
render :show, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
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,23 @@
|
|
|
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, replied: nil, round: nil)
|
|
15
|
+
scope = candidates
|
|
16
|
+
scope = scope.where(id: HiringReply.responded.select(:candidate_id)) if replied.present?
|
|
17
|
+
scope = scope.where(id: HiringReply.responded.where(round: round).where.not(preferred_slot: nil).select(:candidate_id)) if round.present?
|
|
18
|
+
scope = scope.where(stage: stage) if stage.presence_in(HiringCandidate::STAGES)
|
|
19
|
+
scope = scope.where(campus: campus) if campus.present?
|
|
20
|
+
scope.order(:name, :id)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|