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, }) }