Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/careers/scheduling.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/careers/scheduling.go |
| Original SHA-256 | f2e6399f411d4af359cb1d858d85db050a3cef2baa7dbb68cc58f160fef34891 |
| Git blob SHA | 3956fa2263bd6b9474f3dde9a7536144934d0ca8 |
| Reading-copy SHA-256 | f2e6399f411d4af359cb1d858d85db050a3cef2baa7dbb68cc58f160fef34891 |
Recovered source transcript
Download complete source text ↓426 lines · 15.9 KB
package careers
// INTERVIEW SCHEDULING. A recruiter offers times, the candidate picks one, and everybody gets a calendar
// invite.
//
// It lives in this package rather than in recruiting because it shares the thing that is hard: a PUBLIC
// surface reached by somebody with no account, resolved through a SECURITY DEFINER function because a request
// with no session has no organization and row level security correctly denies it everything. Putting it
// beside the careers page keeps one copy of that pattern instead of two.
//
// AN OFFER IS NOT AN INTERVIEW. Offering three times creates an offer with three slots; the interviews row
// appears only when somebody picks one. Creating the interview up front and moving it would leave a pipeline
// full of interviews that never happened, which is exactly the number a hiring manager asks about.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/icalendar"
"github.com/missionworkforce/missionmatch/internal/platform/mailer"
"github.com/missionworkforce/missionmatch/internal/platform/orgsettings"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
var (
ErrOfferUnavailable = errors.New("careers: that scheduling link is no longer available")
ErrSlotTaken = errors.New("careers: that time is no longer available")
ErrNoSlots = errors.New("careers: an offer needs at least one time")
)
// OfferInput creates a scheduling offer.
type OfferInput struct {
ApplicationID string `json:"application_id"`
Kind string `json:"kind"`
Round int `json:"round"`
DurationMins int `json:"duration_mins"`
Location string `json:"location_text"`
Interviewer string `json:"interviewer"`
Message string `json:"message"`
ExpiresInDays int `json:"expires_in_days"`
Slots []time.Time `json:"slots"`
}
// PublicOffer is what the candidate sees. It carries no pipeline detail, no other candidate, and no internal
// identifier: a person choosing a time needs the role, the employer, the length, and the times.
type PublicOffer struct {
Token string `json:"token"`
Employer string `json:"employer"`
Role string `json:"role"`
Candidate string `json:"candidate_name"`
Kind string `json:"kind"`
Duration int `json:"duration_mins"`
Location string `json:"location_text,omitempty"`
Interviewer string `json:"interviewer,omitempty"`
Message string `json:"message,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
Slots []PublicSlot `json:"slots"`
}
type PublicSlot struct {
ID string `json:"id"`
StartsAt time.Time `json:"starts_at"`
EndsAt time.Time `json:"ends_at"`
}
var validInterviewKind = map[string]bool{
"phone_screen": true, "technical": true, "client": true, "panel": true, "final": true,
}
// CreateOffer records an offer and its times, and returns the public link.
func (s *Service) CreateOffer(ctx context.Context, in OfferInput, ip, ua string) (string, string, error) {
if s.db == nil {
return "", "", ErrNoDatabase
}
appID, err := uuid.Parse(strings.TrimSpace(in.ApplicationID))
if err != nil {
return "", "", errors.New("careers: an application identifier is required")
}
if in.Kind == "" {
in.Kind = "client"
}
if !validInterviewKind[in.Kind] {
return "", "", errors.New("careers: unknown interview kind")
}
if in.Round <= 0 {
in.Round = 1
}
if in.DurationMins <= 0 {
in.DurationMins = 45
}
if in.DurationMins < 5 || in.DurationMins > 480 {
return "", "", errors.New("careers: an interview runs between five minutes and eight hours")
}
if in.ExpiresInDays <= 0 {
in.ExpiresInDays = 7
}
if in.ExpiresInDays > 60 {
return "", "", errors.New("careers: a scheduling link may live at most sixty days")
}
if len(in.Slots) == 0 {
return "", "", ErrNoSlots
}
if len(in.Slots) > 20 {
return "", "", errors.New("careers: offer at most twenty times, a longer list is harder to choose from")
}
// A time in the past is refused rather than silently offered, because a candidate clicking it would get
// a conflict they cannot explain.
now := time.Now().UTC()
for _, t := range in.Slots {
if t.Before(now) {
return "", "", errors.New("careers: one of those times is in the past")
}
}
token, err := NewPostingToken()
if err != nil {
return "", "", err
}
actor := actorPtr(ctx)
// A uuid, NOT the text form. Scanning id::text and then passing that string back as a uuid parameter is
// a conversion pgx should not have to guess at, and it is what made the first version of this fail with
// a 500 that the handler turned into a generic message.
var offerUUID uuid.UUID
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
e := tx.QueryRow(ctx, `
INSERT INTO interview_offers (organization_id, application_id, public_token, kind, round,
duration_mins, location_text, interviewer, message, expires_at, created_by)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid,
$1,$2,$3,$4,$5,$6,$7,$8, now() + make_interval(days => $9), $10)
RETURNING id`,
appID, token, in.Kind, in.Round, in.DurationMins, clip(in.Location, 500),
clip(in.Interviewer, 200), clip(in.Message, 2000), in.ExpiresInDays, actor).Scan(&offerUUID)
if e != nil {
return e
}
for _, t := range in.Slots {
// The end is computed HERE rather than in SQL. Writing it as "$2 + make_interval(...)" made
// Postgres deduce two different types for the same parameter, once as a timestamp column and
// once as the left side of an addition, and it refused the statement with SQLSTATE 42P08. Doing
// the arithmetic in Go is also simply clearer about what a slot's length is.
start := t.UTC()
end := start.Add(time.Duration(in.DurationMins) * time.Minute)
if _, e := tx.Exec(ctx, `
INSERT INTO interview_offer_slots (organization_id, offer_id, starts_at, ends_at)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3)`,
offerUUID, start, end); e != nil {
return e
}
}
if s.aud == nil {
return nil
}
body, _ := json.Marshal(map[string]any{
"application_id": appID.String(), "kind": in.Kind, "slots": len(in.Slots),
})
return s.aud.OnTx(ctx, tx, audit.Event{
Action: "interview.offer.created", ActorUserID: actor,
EntityType: "interview_offer", EntityID: offerUUID.String(), NewValues: body,
IPAddress: ip, UserAgent: ua,
})
})
return offerUUID.String(), token, err
}
// GetPublicOffer serves the candidate's choosing page.
func (s *Service) GetPublicOffer(ctx context.Context, slug, token string) (PublicOffer, error) {
var o PublicOffer
if s.db == nil {
return o, ErrNoDatabase
}
if !validToken(token) {
return o, ErrOfferUnavailable
}
org, err := s.resolveOrg(ctx, slug)
if err != nil {
return o, ErrOfferUnavailable
}
err = s.db.RunInTenantTx(tenant.WithOrg(ctx, org.String()), func(ctx context.Context, tx pgx.Tx) error {
var offerID uuid.UUID
e := tx.QueryRow(ctx, `SELECT offer_id, kind, duration_mins, location_text, interviewer, message,
expires_at, candidate_name, requisition_title, organization_name
FROM public_interview_offer($1)`, token).
Scan(&offerID, &o.Kind, &o.Duration, &o.Location, &o.Interviewer, &o.Message,
&o.ExpiresAt, &o.Candidate, &o.Role, &o.Employer)
if errors.Is(e, pgx.ErrNoRows) {
return ErrOfferUnavailable
}
if e != nil {
return e
}
rows, e := tx.Query(ctx, `SELECT id::text, starts_at, ends_at FROM interview_offer_slots
WHERE offer_id = $1 AND available AND starts_at > now()
ORDER BY starts_at`, offerID)
if e != nil {
return e
}
defer rows.Close()
o.Slots = []PublicSlot{}
for rows.Next() {
var sl PublicSlot
if e := rows.Scan(&sl.ID, &sl.StartsAt, &sl.EndsAt); e != nil {
return e
}
o.Slots = append(o.Slots, sl)
}
return rows.Err()
})
o.Token = token
return o, err
}
// BookSlot is the candidate choosing a time. It creates the interview, closes the offer, and sends the
// calendar invite.
//
// THE SLOT IS TAKEN UNDER A CONDITIONAL UPDATE, not a read then write. Two candidates cannot race here, but a
// candidate double clicking absolutely can, and a check followed by an update would book the same time twice
// and create two interviews.
func (s *Service) BookSlot(ctx context.Context, slug, token, slotID, ip string) (time.Time, error) {
var when time.Time
if s.db == nil {
return when, ErrNoDatabase
}
if !validToken(token) {
return when, ErrOfferUnavailable
}
sid, err := uuid.Parse(strings.TrimSpace(slotID))
if err != nil {
return when, ErrSlotTaken
}
org, err := s.resolveOrg(ctx, slug)
if err != nil {
return when, ErrOfferUnavailable
}
tctx := tenant.WithOrg(ctx, org.String())
var invite mailer.Message
var cfg mailer.Config
var send bool
err = s.db.RunInTenantTx(tctx, func(ctx context.Context, tx pgx.Tx) error {
var offerID uuid.UUID
var kind, location, interviewer, candName, role, employer string
var duration, round int
var appID uuid.UUID
e := tx.QueryRow(ctx, `
SELECT o.id, o.application_id, o.kind, o.round, o.duration_mins, o.location_text, o.interviewer,
c.full_name, r.title, org.name
FROM interview_offers o
JOIN applications a ON a.id = o.application_id
JOIN candidates c ON c.id = a.candidate_id
JOIN requisitions r ON r.id = a.requisition_id
JOIN organizations org ON org.id = o.organization_id
WHERE o.public_token = $1 AND o.status = 'open' AND o.expires_at > now()`, token).
Scan(&offerID, &appID, &kind, &round, &duration, &location, &interviewer,
&candName, &role, &employer)
if errors.Is(e, pgx.ErrNoRows) {
return ErrOfferUnavailable
}
if e != nil {
return e
}
// CONDITIONAL: the row is only taken if it is still available. A double click loses the race here
// rather than producing a second interview.
var ends time.Time
e = tx.QueryRow(ctx, `UPDATE interview_offer_slots SET available = false
WHERE id = $1 AND offer_id = $2 AND available AND starts_at > now()
RETURNING starts_at, ends_at`, sid, offerID).Scan(&when, &ends)
if errors.Is(e, pgx.ErrNoRows) {
return ErrSlotTaken
}
if e != nil {
return e
}
var interviewID uuid.UUID
e = tx.QueryRow(ctx, `
INSERT INTO interviews (organization_id, application_id, round, kind, scheduled_at,
outcome, interviewer)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1,$2,$3,$4,'scheduled',$5)
RETURNING id`, appID, round, kind, when, interviewer).Scan(&interviewID)
if e != nil {
return e
}
if _, e := tx.Exec(ctx, `UPDATE interview_offers
SET status = 'booked', booked_slot_id = $2, interview_id = $3, updated_at = now()
WHERE id = $1`, offerID, sid, interviewID); e != nil {
return e
}
// The remaining times are withdrawn, because an offer that has been taken should not still show
// choices a candidate could click.
if _, e := tx.Exec(ctx, `UPDATE interview_offer_slots SET available = false
WHERE offer_id = $1 AND id <> $2`, offerID, sid); e != nil {
return e
}
// The invitation is ASSEMBLED here and SENT after the transaction commits, so a mail failure cannot
// roll back a booking the candidate has already been told about.
var candEmail string
_ = tx.QueryRow(ctx, `SELECT coalesce(c.email,'') FROM applications a
JOIN candidates c ON c.id = a.candidate_id WHERE a.id = $1`, appID).Scan(&candEmail)
if candEmail != "" && s.settings != nil {
st, se := s.settings.LoadTx(ctx, tx)
if se == nil && st.EmailDeliveryEnabled && st.SMTPVerifiedAt != nil &&
st.SMTPHost != "" && st.EmailFromAddress != "" {
pw, _ := s.settings.OpenSecret(ctx, tx, orgsettings.SecretSMTPPassword)
cfg = mailer.Config{
Host: st.SMTPHost, Port: st.SMTPPort, Username: st.SMTPUsername, Password: pw,
Security: st.SMTPSecurity, From: st.EmailFromAddress, FromName: st.EmailFromName,
ReplyTo: st.EmailReplyTo,
}
summary := fmt.Sprintf("Interview, %s, with %s", role, employer)
ics := icalendar.Render(icalendar.Event{
UID: "interview-" + interviewID.String() + "@missionmatch",
Summary: summary,
Description: strings.TrimSpace("Interview for " + role + ". " + interviewer),
Location: location,
Start: when,
End: ends,
OrganizerName: st.EmailFromName,
OrganizerEmail: st.EmailFromAddress,
AttendeeName: candName,
AttendeeEmail: candEmail,
})
invite = mailer.Message{
To: candEmail, Subject: summary,
Body: fmt.Sprintf("Your interview is confirmed for %s.\r\n\r\n%s\r\n\r\n%s\r\n",
when.UTC().Format("Monday 2 January 2006 at 15:04 MST"), location,
"A calendar invitation is attached."),
Calendar: ics, CalendarMethod: "REQUEST",
}
send = true
}
}
if s.aud == nil {
return nil
}
body, _ := json.Marshal(map[string]any{
"offer_id": offerID.String(), "interview_id": interviewID.String(), "scheduled_at": when,
})
return s.aud.OnTx(ctx, tx, audit.Event{
Action: "interview.booked", EntityType: "interview", EntityID: interviewID.String(),
NewValues: body, IPAddress: ip,
})
})
if err != nil {
return when, err
}
if send {
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// A failed invitation does NOT fail the booking. The time is taken and the interview exists; the
// candidate simply did not get a calendar entry, which a recruiter can fix by hand.
_ = mailer.Send(sctx, cfg, invite)
}
return when, nil
}
// WithdrawOffer closes an offer nobody booked.
func (s *Service) WithdrawOffer(ctx context.Context, id uuid.UUID, ip, ua string) error {
if s.db == nil {
return ErrNoDatabase
}
actor := actorPtr(ctx)
return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
tag, e := tx.Exec(ctx, `UPDATE interview_offers SET status = 'withdrawn', updated_at = now()
WHERE id = $1 AND status = 'open'`, id)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, e := tx.Exec(ctx, `UPDATE interview_offer_slots SET available = false WHERE offer_id = $1`, id); e != nil {
return e
}
if s.aud == nil {
return nil
}
return s.aud.OnTx(ctx, tx, audit.Event{
Action: "interview.offer.withdrawn", ActorUserID: actor,
EntityType: "interview_offer", EntityID: id.String(), IPAddress: ip, UserAgent: ua,
})
})
}
// ListOffers shows a recruiter what is outstanding.
func (s *Service) ListOffers(ctx context.Context) ([]map[string]any, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
out := make([]map[string]any, 0, 32)
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, e := tx.Query(ctx, `
SELECT o.id::text, o.public_token, o.status, o.kind, o.expires_at, c.full_name, r.title,
(SELECT count(*)::int FROM interview_offer_slots s WHERE s.offer_id = o.id AND s.available)
FROM interview_offers o
JOIN applications a ON a.id = o.application_id
JOIN candidates c ON c.id = a.candidate_id
JOIN requisitions r ON r.id = a.requisition_id
ORDER BY o.created_at DESC LIMIT 200`)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var id, token, status, kind, cand, role string
var exp time.Time
var open int
if e := rows.Scan(&id, &token, &status, &kind, &exp, &cand, &role, &open); e != nil {
return e
}
out = append(out, map[string]any{
"id": id, "public_token": token, "status": status, "kind": kind,
"expires_at": exp, "candidate_name": cand, "role": role, "times_available": open,
})
}
return rows.Err()
})
return out, err
}