Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/modules/recruiting/service.go |
| Original SHA-256 | 0170e7656315a96a50779423de585ce534d19e5f5849880923f9fd5e11ae617f |
| Git blob SHA | 5ba6e8fb64fd71e2594bed1dfd8506f9b5199777 |
| Reading-copy SHA-256 | 0170e7656315a96a50779423de585ce534d19e5f5849880923f9fd5e11ae617f |
Recovered source transcript
Download complete source text ↓590 lines · 20.1 KB
package recruiting
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
const (
maxName = 200
maxTitle = 200
maxFreeText = 4000
defaultPage = 50
maxPage = 200
)
var (
clientStatuses = map[string]bool{"prospect": true, "active": true, "inactive": true}
contactStatuses = map[string]bool{"active": true, "inactive": true}
)
type Service struct {
db *tenant.DB
audit *audit.Writer
clock func() time.Time
}
func NewService(db *tenant.DB, aud *audit.Writer) *Service {
return &Service{db: db, audit: aud, clock: time.Now}
}
func actorPtr(ctx context.Context) *uuid.UUID {
p, ok := tenant.UserFromContext(ctx)
if !ok {
return nil
}
id, err := uuid.Parse(p.UserID)
if err != nil {
return nil
}
return &id
}
// clean trims and bounds a free text field. Length is validated in Go as well as in the database so the
// caller gets a named field back rather than a constraint name.
func clean(field, v string, max int) (string, error) {
t := strings.TrimSpace(v)
if len(t) > max {
return "", &ValidationError{Field: field, Message: "must be at most " + itoa(max) + " characters"}
}
return t, nil
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// ---------------------------------------------------------------------------
// Clients
// ---------------------------------------------------------------------------
// ListClients filters by status, free text, and ownership. The owner filter is server side rather than
// a client side pass over the page, because filtering after pagination would show a recruiter three of
// their accounts on a page of fifty and call it their book.
//
// owner accepts "me", "unassigned", or a user id. Anything else is refused rather than silently ignored,
// since a typo that quietly returned everyone else's accounts is worse than an error.
func (s *Service) ListClients(ctx context.Context, status, search, ownerFilter string, limit, offset int) (ClientList, error) {
if s.db == nil {
return ClientList{}, ErrNoDatabase
}
if limit <= 0 || limit > maxPage {
limit = defaultPage
}
if offset < 0 {
offset = 0
}
if status != "" && !clientStatuses[status] {
return ClientList{}, &ValidationError{Field: "status", Message: "is not a permitted value"}
}
// ownerMode drives a three way branch in SQL: 0 no filter, 1 a specific user, 2 unassigned only.
var ownerMode int
var ownerID *uuid.UUID
switch ownerFilter {
case "":
ownerMode = 0
case "unassigned":
ownerMode = 2
case "me":
ownerMode, ownerID = 1, actorPtr(ctx)
if ownerID == nil {
return ClientList{}, tenant.ErrNoTenant
}
default:
id, err := uuid.Parse(ownerFilter)
if err != nil {
return ClientList{}, &ValidationError{Field: "owner", Message: "must be me, unassigned, or a user id"}
}
ownerMode, ownerID = 1, &id
}
out := ClientList{Clients: []Client{}, Limit: limit, Offset: offset}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// One query for the page and one for the total. A window function would fuse them, but it also
// forces the planner to materialize every matching row to compute the count, which is slower
// once a client list is long. Two bounded queries stay predictable.
rows, err := tx.Query(ctx, `
SELECT c.id::text, c.name, c.uei, c.industry, c.website, c.status, c.notes,
(SELECT count(*) FROM client_contacts k
WHERE k.organization_id = c.organization_id AND k.client_id = c.id AND k.status = 'active'),
c.owner_user_id::text, COALESCE(u.full_name,''), c.owner_since,
c.created_at, c.updated_at
FROM clients c
LEFT JOIN users u ON u.organization_id = c.organization_id AND u.id = c.owner_user_id
WHERE ($1 = '' OR c.status = $1)
AND ($2 = '' OR c.name ILIKE '%' || $2 || '%' OR c.uei ILIKE '%' || $2 || '%')
AND ($5 = 0
OR ($5 = 1 AND c.owner_user_id = $6)
OR ($5 = 2 AND c.owner_user_id IS NULL))
ORDER BY c.name, c.id
LIMIT $3 OFFSET $4`, status, search, limit, offset, ownerMode, ownerID)
if err != nil {
return translatePG(err)
}
defer rows.Close()
for rows.Next() {
var c Client
var created, updated time.Time
var ownerID *string
var ownerSince *time.Time
if err := rows.Scan(&c.ID, &c.Name, &c.UEI, &c.Industry, &c.Website, &c.Status, &c.Notes,
&c.ContactCount, &ownerID, &c.OwnerName, &ownerSince, &created, &updated); err != nil {
return translatePG(err)
}
if ownerID != nil {
c.OwnerUserID = *ownerID
}
if ownerSince != nil {
c.OwnerSince = ts(*ownerSince)
}
c.CreatedAt, c.UpdatedAt = ts(created), ts(updated)
out.Clients = append(out.Clients, c)
}
if err := rows.Err(); err != nil {
return translatePG(err)
}
return tx.QueryRow(ctx, `
SELECT count(*) FROM clients c
WHERE ($1 = '' OR c.status = $1)
AND ($2 = '' OR c.name ILIKE '%' || $2 || '%' OR c.uei ILIKE '%' || $2 || '%')
AND ($3 = 0
OR ($3 = 1 AND c.owner_user_id = $4)
OR ($3 = 2 AND c.owner_user_id IS NULL))`,
status, search, ownerMode, ownerID).Scan(&out.Total)
})
if err != nil {
return ClientList{}, err
}
return out, nil
}
func (s *Service) GetClient(ctx context.Context, id uuid.UUID) (ClientDetail, error) {
if s.db == nil {
return ClientDetail{}, ErrNoDatabase
}
var out ClientDetail
out.Contacts = []Contact{}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var created, updated time.Time
var ownerID *string
var ownerSince *time.Time
err := tx.QueryRow(ctx, `
SELECT c.id::text, c.name, c.uei, c.industry, c.website, c.status, c.notes,
c.owner_user_id::text, COALESCE(u.full_name,''), c.owner_since, c.created_at, c.updated_at
FROM clients c
LEFT JOIN users u ON u.organization_id = c.organization_id AND u.id = c.owner_user_id
WHERE c.id = $1`, id).
Scan(&out.ID, &out.Name, &out.UEI, &out.Industry, &out.Website, &out.Status, &out.Notes,
&ownerID, &out.OwnerName, &ownerSince, &created, &updated)
if err != nil {
if err == pgx.ErrNoRows {
return ErrNotFound
}
return translatePG(err)
}
if ownerID != nil {
out.OwnerUserID = *ownerID
}
if ownerSince != nil {
out.OwnerSince = ts(*ownerSince)
}
out.CreatedAt, out.UpdatedAt = ts(created), ts(updated)
rows, err := tx.Query(ctx, `
SELECT id::text, client_id::text, full_name, title, email, phone,
is_hiring_manager, is_primary, status, created_at, updated_at
FROM client_contacts WHERE client_id = $1
ORDER BY is_primary DESC, full_name, id`, id)
if err != nil {
return translatePG(err)
}
defer rows.Close()
for rows.Next() {
var c Contact
var cc, cu time.Time
if err := rows.Scan(&c.ID, &c.ClientID, &c.FullName, &c.Title, &c.Email, &c.Phone,
&c.IsHiringManager, &c.IsPrimary, &c.Status, &cc, &cu); err != nil {
return translatePG(err)
}
c.CreatedAt, c.UpdatedAt = ts(cc), ts(cu)
out.Contacts = append(out.Contacts, c)
}
out.ContactCount = len(out.Contacts)
return translatePG(rows.Err())
})
if err != nil {
return ClientDetail{}, err
}
return out, nil
}
func (s *Service) CreateClient(ctx context.Context, in CreateClientInput) (Client, error) {
if s.db == nil {
return Client{}, ErrNoDatabase
}
name, err := clean("name", in.Name, maxName)
if err != nil {
return Client{}, err
}
if name == "" {
return Client{}, &ValidationError{Field: "name", Message: "is required"}
}
uei, err := clean("uei", in.UEI, 32)
if err != nil {
return Client{}, err
}
industry, err := clean("industry", in.Industry, maxName)
if err != nil {
return Client{}, err
}
website, err := clean("website", in.Website, 500)
if err != nil {
return Client{}, err
}
notes, err := clean("notes", in.Notes, maxFreeText)
if err != nil {
return Client{}, err
}
status := in.Status
if status == "" {
status = "active"
}
if !clientStatuses[status] {
return Client{}, &ValidationError{Field: "status", Message: "is not a permitted value"}
}
var out Client
// The new id is captured as a uuid.UUID alongside the string form the DTO carries, rather than being
// parsed back out of out.ID with uuid.MustParse after the commit. See CreateApplication: a database
// returned id has no legitimate failure mode, so the only thing that parse could ever do is panic in a
// request path, and here it would do so after the client row is already durably written.
var newID uuid.UUID
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var created, updated time.Time
var ownerID *string
var ownerSince *time.Time
err := tx.QueryRow(ctx, `
INSERT INTO clients (organization_id, name, uei, industry, website, status, notes, created_by,
owner_user_id, owner_since)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, $4, $5, $6, $7,
$7, CASE WHEN $7::uuid IS NULL THEN NULL ELSE now() END)
RETURNING id, owner_user_id::text, owner_since, created_at, updated_at`,
name, uei, industry, website, status, notes, actorPtr(ctx)).
Scan(&newID, &ownerID, &ownerSince, &created, &updated)
if err != nil {
return translatePG(err)
}
out.ID = newID.String()
out.Name, out.UEI, out.Industry, out.Website, out.Status, out.Notes = name, uei, industry, website, status, notes
if ownerID != nil {
out.OwnerUserID = *ownerID
}
if ownerSince != nil {
out.OwnerSince = ts(*ownerSince)
}
out.CreatedAt, out.UpdatedAt = ts(created), ts(updated)
nv, _ := json.Marshal(out)
return s.audit.OnTx(ctx, tx, audit.Event{
Action: "client.created", ActorUserID: actorPtr(ctx),
EntityType: "client", EntityID: out.ID, NewValues: nv,
})
})
if err != nil {
return Client{}, err
}
// Re-read rather than return what was assembled here. The INSERT cannot resolve the owner's display
// name without a join, and a create that returned a different shape than a get would make the client
// list flicker the moment a new row was added.
return s.clientRow(ctx, newID)
}
// UpdateClient applies a partial update under optimistic concurrency. COALESCE lets one statement handle
// every combination of present and absent fields without building SQL by hand, and the updated_at match
// is what turns a lost update into a refusal.
func (s *Service) UpdateClient(ctx context.Context, id uuid.UUID, in UpdateClientInput) (Client, error) {
if s.db == nil {
return Client{}, ErrNoDatabase
}
expected, err := parseToken(in.ExpectedUpdatedAt)
if err != nil {
return Client{}, err
}
if in.Name != nil {
n, e := clean("name", *in.Name, maxName)
if e != nil {
return Client{}, e
}
if n == "" {
return Client{}, &ValidationError{Field: "name", Message: "is required"}
}
in.Name = &n
}
if in.Status != nil && !clientStatuses[*in.Status] {
return Client{}, &ValidationError{Field: "status", Message: "is not a permitted value"}
}
var out Client
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Ownership is the lock. This refuses the write outright when someone else holds the account,
// naming them, rather than letting two people race and telling the loser afterwards.
claim, err := assertCanEdit(ctx, tx, id)
if err != nil {
return err
}
if claim {
if err := s.setOwner(ctx, tx, id, actorPtr(ctx), "client.claimed"); err != nil {
return err
}
}
var created, updated time.Time
err = tx.QueryRow(ctx, `
UPDATE clients SET
name = COALESCE($2, name),
uei = COALESCE($3, uei),
industry = COALESCE($4, industry),
website = COALESCE($5, website),
status = COALESCE($6, status),
notes = COALESCE($7, notes),
updated_at = now()
WHERE id = $1 AND ($8::timestamptz IS NULL OR updated_at = $8)
RETURNING id::text, name, uei, industry, website, status, notes, created_at, updated_at`,
id, in.Name, in.UEI, in.Industry, in.Website, in.Status, in.Notes, tokenOrNil(expected, claim)).
Scan(&out.ID, &out.Name, &out.UEI, &out.Industry, &out.Website, &out.Status, &out.Notes, &created, &updated)
if err != nil {
if err == pgx.ErrNoRows {
// Either the row is gone or it moved. Distinguishing them tells the caller whether to
// reload or to report a deletion, and costs one cheap indexed read.
var exists bool
if e := tx.QueryRow(ctx, `SELECT true FROM clients WHERE id = $1`, id).Scan(&exists); e != nil {
return ErrNotFound
}
return ErrConflict
}
return translatePG(err)
}
out.CreatedAt, out.UpdatedAt = ts(created), ts(updated)
nv, _ := json.Marshal(out)
return s.audit.OnTx(ctx, tx, audit.Event{
Action: "client.updated", ActorUserID: actorPtr(ctx),
EntityType: "client", EntityID: out.ID, NewValues: nv,
})
})
if err != nil {
return Client{}, err
}
return out, nil
}
// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
// CreateContact adds a person to a client. Promoting to primary demotes the incumbent in the SAME
// transaction, because a partial unique index permits exactly one primary per client and inserting
// before demoting would be refused. Order matters and both statements share one transaction, so a
// crash between them cannot leave a client with zero or two primaries.
func (s *Service) CreateContact(ctx context.Context, clientID uuid.UUID, in CreateContactInput) (Contact, error) {
if s.db == nil {
return Contact{}, ErrNoDatabase
}
name, err := clean("full_name", in.FullName, maxName)
if err != nil {
return Contact{}, err
}
if name == "" {
return Contact{}, &ValidationError{Field: "full_name", Message: "is required"}
}
title, err := clean("title", in.Title, maxTitle)
if err != nil {
return Contact{}, err
}
email, err := clean("email", in.Email, 320)
if err != nil {
return Contact{}, err
}
phone, err := clean("phone", in.Phone, 64)
if err != nil {
return Contact{}, err
}
var out Contact
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if _, err := assertCanEdit(ctx, tx, clientID); err != nil {
return err
}
if in.IsPrimary {
if _, err := tx.Exec(ctx,
`UPDATE client_contacts SET is_primary = false, updated_at = now()
WHERE client_id = $1 AND is_primary`, clientID); err != nil {
return translatePG(err)
}
}
var created, updated time.Time
err := tx.QueryRow(ctx, `
INSERT INTO client_contacts
(organization_id, client_id, full_name, title, email, phone, is_hiring_manager, is_primary)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, $4, $5, $6, $7)
RETURNING id::text, client_id::text, status, created_at, updated_at`,
clientID, name, title, email, phone, in.IsHiringManager, in.IsPrimary).
Scan(&out.ID, &out.ClientID, &out.Status, &created, &updated)
if err != nil {
return translatePG(err)
}
out.FullName, out.Title, out.Email, out.Phone = name, title, email, phone
out.IsHiringManager, out.IsPrimary = in.IsHiringManager, in.IsPrimary
out.CreatedAt, out.UpdatedAt = ts(created), ts(updated)
nv, _ := json.Marshal(out)
return s.audit.OnTx(ctx, tx, audit.Event{
Action: "client_contact.created", ActorUserID: actorPtr(ctx),
EntityType: "client_contact", EntityID: out.ID, NewValues: nv,
})
})
if err != nil {
return Contact{}, err
}
return out, nil
}
func (s *Service) UpdateContact(ctx context.Context, id uuid.UUID, in UpdateContactInput) (Contact, error) {
if s.db == nil {
return Contact{}, ErrNoDatabase
}
expected, err := parseToken(in.ExpectedUpdatedAt)
if err != nil {
return Contact{}, err
}
if in.FullName != nil {
n, e := clean("full_name", *in.FullName, maxName)
if e != nil {
return Contact{}, e
}
if n == "" {
return Contact{}, &ValidationError{Field: "full_name", Message: "is required"}
}
in.FullName = &n
}
if in.Status != nil && !contactStatuses[*in.Status] {
return Contact{}, &ValidationError{Field: "status", Message: "is not a permitted value"}
}
var out Contact
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// Demote the incumbent before promoting, and scope the demotion to this contact's own client so
// a promotion never touches another client's primary.
if in.IsPrimary != nil && *in.IsPrimary {
if _, err := tx.Exec(ctx, `
UPDATE client_contacts SET is_primary = false, updated_at = now()
WHERE is_primary AND id <> $1
AND client_id = (SELECT client_id FROM client_contacts WHERE id = $1)`, id); err != nil {
return translatePG(err)
}
}
var created, updated time.Time
err := tx.QueryRow(ctx, `
UPDATE client_contacts SET
full_name = COALESCE($2, full_name),
title = COALESCE($3, title),
email = COALESCE($4, email),
phone = COALESCE($5, phone),
is_hiring_manager = COALESCE($6, is_hiring_manager),
is_primary = COALESCE($7, is_primary),
status = COALESCE($8, status),
updated_at = now()
WHERE id = $1 AND updated_at = $9
RETURNING id::text, client_id::text, full_name, title, email, phone,
is_hiring_manager, is_primary, status, created_at, updated_at`,
id, in.FullName, in.Title, in.Email, in.Phone, in.IsHiringManager, in.IsPrimary, in.Status, expected).
Scan(&out.ID, &out.ClientID, &out.FullName, &out.Title, &out.Email, &out.Phone,
&out.IsHiringManager, &out.IsPrimary, &out.Status, &created, &updated)
if err != nil {
if err == pgx.ErrNoRows {
var exists bool
if e := tx.QueryRow(ctx, `SELECT true FROM client_contacts WHERE id = $1`, id).Scan(&exists); e != nil {
return ErrNotFound
}
return ErrConflict
}
return translatePG(err)
}
out.CreatedAt, out.UpdatedAt = ts(created), ts(updated)
nv, _ := json.Marshal(out)
return s.audit.OnTx(ctx, tx, audit.Event{
Action: "client_contact.updated", ActorUserID: actorPtr(ctx),
EntityType: "client_contact", EntityID: out.ID, NewValues: nv,
})
})
if err != nil {
return Contact{}, err
}
return out, nil
}
// parseToken reads the optimistic concurrency token. It is required: allowing an update without one
// would quietly reintroduce last write wins for any caller that forgot to send it.
func parseToken(v string) (time.Time, error) {
if strings.TrimSpace(v) == "" {
return time.Time{}, &ValidationError{Field: "expected_updated_at", Message: "is required"}
}
t, err := time.Parse(time.RFC3339Nano, v)
if err != nil {
return time.Time{}, &ValidationError{Field: "expected_updated_at", Message: "must be an RFC3339 timestamp"}
}
return t, nil
}
// tokenOrNil drops the optimistic token when this same transaction just claimed the account. Claiming
// bumps updated_at, so the caller's token is legitimately stale by one write that they themselves
// caused, and enforcing it would refuse every first edit of an unowned client.
func tokenOrNil(expected time.Time, claimed bool) any {
if claimed {
return nil
}
return expected
}
// clientRow re-reads a client after an ownership change so the caller gets the current row rather than
// assembling one from what it hoped it wrote.
func (s *Service) clientRow(ctx context.Context, id uuid.UUID) (Client, error) {
d, err := s.GetClient(ctx, id)
if err != nil {
return Client{}, err
}
return d.Client, nil
}
// auditOwnership records who took or released a record. Ownership changes are the audit trail a
// manager reads when two people disagree about who was working something.
//
// entityType is a parameter rather than a constant because clients and requisitions both use this lock,
// and filing a job order claim under "client" would make the trail say something that is not true.
func (s *Service) auditOwnership(ctx context.Context, tx pgx.Tx, entityType string, id uuid.UUID, to *uuid.UUID, action string) error {
payload := map[string]any{entityType + "_id": id.String()}
if to != nil {
payload["owner_user_id"] = to.String()
}
nv, _ := json.Marshal(payload)
return s.audit.OnTx(ctx, tx, audit.Event{
Action: action, ActorUserID: actorPtr(ctx),
EntityType: entityType, EntityID: id.String(), NewValues: nv,
})
}