Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/collab/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/collab/service.go |
| Original SHA-256 | 49aec87de71fdcf0a2a8160e540381ae88ac1a659995c7e95218d9bf28b25d55 |
| Git blob SHA | 18a527ed0e6d831398a2e0b513148741b5e45fb5 |
| Reading-copy SHA-256 | 49aec87de71fdcf0a2a8160e540381ae88ac1a659995c7e95218d9bf28b25d55 |
Recovered source transcript
Download complete source text ↓543 lines · 20.4 KB
// Package collab answers one question a recruiting floor asks all day: is anybody already on this.
//
// WHY IT IS ITS OWN MODULE. The question is identical for a candidate, a requisition, an opportunity, a
// proposal and a client, and the answer has to look the same for all of them. Putting it in the recruiting
// module would have made the missionmatch module import a sibling to ask about a candidate; putting a copy in
// each would have produced five answers that drift.
//
// IT IS NOT A SECOND OWNERSHIP SYSTEM. clients and requisitions have carried owner_user_id since migration
// 0047, with claim and reassign in internal/modules/recruiting/ownership.go, and that column stays
// authoritative for whose desk a record belongs to. A claim here is shorter lived and answers something
// else: not "this account is mine" but "I am on this right now, do not also call them today". The read
// surface returns BOTH, because a candidate sitting on Dana's desk while Marcus submits them this afternoon
// is two true facts and a recruiter needs each. CANDIDATES have neither today, and a candidate phoned twice
// in one day by the same agency is the complaint this closes.
//
// A CLAIM IS NOT A LOCK. Claims expire, and claim_required is off by default. Even with it on, a second
// person is warned rather than blocked, because an agency that cannot move a desk at five o'clock on a
// deadline day loses the award. The product's job here is to make the collision VISIBLE, not to arbitrate it.
package collab
import (
"context"
"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/orgsettings"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
var (
ErrNoDatabase = errors.New("collab: no database configured")
ErrUnknownType = errors.New("collab: unknown entity type")
ErrNotFound = errors.New("collab: no such record in this organization")
ErrClaimedByOther = errors.New("collab: someone else already holds a live claim on this record")
ErrNotYours = errors.New("collab: that claim belongs to someone else")
)
// entityTables maps the closed vocabulary the database CHECK already enforces onto the table each type lives
// in. IT IS A CLOSED MAP AND THAT IS THE POINT: the table name is interpolated into SQL, so it may only ever
// come from here, never from a request. A caller supplying an unknown type gets ErrUnknownType before any
// query is built.
var entityTables = map[string]string{
"candidate": "candidates",
"requisition": "requisitions",
"opportunity": "opportunities",
"proposal": "proposals",
"client": "clients",
}
// ownerColumns names the types that ALSO carry desk ownership, so the read can return the existing
// owner_user_id alongside the claim rather than pretending claims are the only notion of who has this.
var ownerColumns = map[string]string{
"requisition": "owner_user_id",
"client": "owner_user_id",
}
func ValidEntityType(t string) bool { _, ok := entityTables[t]; return ok }
type Service struct {
db *tenant.DB
settings *orgsettings.Store
aud *audit.Writer
clock func() time.Time
}
func NewService(db *tenant.DB, settings *orgsettings.Store, aud *audit.Writer) *Service {
return &Service{db: db, settings: settings, aud: aud, clock: time.Now}
}
// Claim is one person saying they are working a record now.
type Claim struct {
ID string `json:"id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Activity string `json:"activity"`
Note string `json:"note"`
ClaimedAt time.Time `json:"claimed_at"`
ExpiresAt time.Time `json:"expires_at"`
ReleasedAt *time.Time `json:"released_at,omitempty"`
// Mine lets the interface show "you have this" without comparing identifiers in the browser.
Mine bool `json:"mine"`
}
// Touch is the soft signal: somebody looked at this recently.
type Touch struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
LastSeenAt time.Time `json:"last_seen_at"`
Mine bool `json:"mine"`
}
// Activity is the whole answer for one record: who owns the desk, who is on it now, and who has looked at it
// lately, already filtered by what this caller is allowed to see.
type Activity struct {
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
Claim *Claim `json:"claim"`
Touches []Touch `json:"touches"`
// DeskOwner is the existing owner_user_id from migration 0047 where the entity type has one. It is a
// DIFFERENT fact from Claim and both are returned deliberately.
DeskOwnerUserID string `json:"desk_owner_user_id,omitempty"`
DeskOwnerName string `json:"desk_owner_name,omitempty"`
// Visible reports whether this caller may see other people's activity at all. When it is false the
// claim and touch lists carry only the caller's OWN entries, never a silently empty answer that would
// read as "nobody is on this".
Visible bool `json:"visible_to_you"`
// ClaimRequired mirrors the organization's setting so the interface can warn before a second person
// starts work. It is a warning, never a block.
ClaimRequired bool `json:"claim_required"`
}
// ClaimInput is a request to take a record.
type ClaimInput struct {
Activity string `json:"activity"`
Note string `json:"note"`
// Steal takes a record that somebody else holds. It is explicit, audited, and never implicit, so the
// trail distinguishes a colleague coordinating from a record changing hands behind someone's back.
Steal bool `json:"steal"`
}
var validActivities = map[string]bool{
"working": true, "screening": true, "submitting": true, "interviewing": true, "negotiating": true,
}
// TakeClaim records that the caller is working this record.
func (s *Service) TakeClaim(ctx context.Context, entityType string, entityID uuid.UUID, in ClaimInput, ip, ua string) (Activity, error) {
if s.db == nil {
return Activity{}, ErrNoDatabase
}
if !ValidEntityType(entityType) {
return Activity{}, ErrUnknownType
}
actor, ok := tenant.UserFromContext(ctx)
if !ok {
return Activity{}, tenant.ErrNoTenant
}
actorID, err := uuid.Parse(actor.UserID)
if err != nil {
return Activity{}, tenant.ErrNoTenant
}
if in.Activity == "" {
in.Activity = "working"
}
if !validActivities[in.Activity] {
return Activity{}, fmt.Errorf("collab: %q is not a recognised activity", in.Activity)
}
if len(in.Note) > 280 {
in.Note = in.Note[:280]
}
var out Activity
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// The record must exist IN THIS TENANT. The query runs under RLS, so a record belonging to another
// organization simply is not there, and the caller learns nothing about whether it exists elsewhere.
if e := assertExists(ctx, tx, entityType, entityID); e != nil {
return e
}
st, e := s.settings.LoadTx(ctx, tx)
if e != nil {
return e
}
// EXPIRED CLAIMS ARE RELEASED FIRST. The unique index that makes "who is on this" a single row
// answer covers every unreleased claim, expired or not, so without this an abandoned claim would own
// a candidate forever and no one could take it.
if _, e := tx.Exec(ctx, `
UPDATE work_claims SET released_at = now()
WHERE entity_type = $1 AND entity_id = $2 AND released_at IS NULL AND expires_at <= now()`,
entityType, entityID); e != nil {
return e
}
var holder uuid.UUID
e = tx.QueryRow(ctx, `
SELECT user_id FROM work_claims
WHERE entity_type = $1 AND entity_id = $2 AND released_at IS NULL`, entityType, entityID).Scan(&holder)
switch {
case e != nil && !errors.Is(e, pgx.ErrNoRows):
return e
case e == nil && holder == actorID:
// Already theirs. Extend it rather than refusing, because a recruiter pressing claim again means
// "I am still on this", which is exactly what the expiry needs to hear.
_, e = tx.Exec(ctx, `
UPDATE work_claims SET expires_at = now() + make_interval(hours => $1), activity = $2,
note = CASE WHEN $3 = '' THEN note ELSE $3 END
WHERE entity_type = $4 AND entity_id = $5 AND released_at IS NULL`,
st.ClaimExpiryHours, in.Activity, in.Note, entityType, entityID)
if e != nil {
return e
}
out, e = s.activityTx(ctx, tx, entityType, entityID, actorID, security.Role(actor.Role), st)
return e
case e == nil && !in.Steal:
return ErrClaimedByOther
case e == nil && in.Steal:
if _, e := tx.Exec(ctx, `
UPDATE work_claims SET released_at = now(), released_by = $1
WHERE entity_type = $2 AND entity_id = $3 AND released_at IS NULL`,
actorID, entityType, entityID); e != nil {
return e
}
// A takeover is audited on its own, separately from the claim, because "Marcus took this from
// Dana" is the event a manager needs to be able to find later.
if s.aud != nil {
body, _ := marshalJSON(map[string]any{
"entity_type": entityType, "entity_id": entityID.String(),
"taken_from": holder.String(), "taken_by": actorID.String(),
})
if e := s.aud.OnTx(ctx, tx, audit.Event{
Action: "work.claim.taken_over", ActorUserID: &actorID,
EntityType: entityType, EntityID: entityID.String(), NewValues: body,
IPAddress: ip, UserAgent: ua,
}); e != nil {
return e
}
}
}
if _, e := tx.Exec(ctx, `
INSERT INTO work_claims (organization_id, entity_type, entity_id, user_id, activity, note, expires_at)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, $4, $5,
now() + make_interval(hours => $6))`,
entityType, entityID, actorID, in.Activity, in.Note, st.ClaimExpiryHours); e != nil {
return e
}
out, e = s.activityTx(ctx, tx, entityType, entityID, actorID, security.Role(actor.Role), st)
return e
})
return out, err
}
// ReleaseClaim gives a record back. A manager may release somebody else's, because a recruiter who leaves at
// lunch and does not come back must not hold a candidate until the expiry runs out.
func (s *Service) ReleaseClaim(ctx context.Context, entityType string, entityID uuid.UUID, ip, ua string) (Activity, error) {
if s.db == nil {
return Activity{}, ErrNoDatabase
}
if !ValidEntityType(entityType) {
return Activity{}, ErrUnknownType
}
actor, ok := tenant.UserFromContext(ctx)
if !ok {
return Activity{}, tenant.ErrNoTenant
}
actorID, err := uuid.Parse(actor.UserID)
if err != nil {
return Activity{}, tenant.ErrNoTenant
}
role := security.Role(actor.Role)
isManager := security.AtLeast(role, security.RoleManager)
var out Activity
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var holder uuid.UUID
e := tx.QueryRow(ctx, `
SELECT user_id FROM work_claims
WHERE entity_type = $1 AND entity_id = $2 AND released_at IS NULL`, entityType, entityID).Scan(&holder)
if errors.Is(e, pgx.ErrNoRows) {
// Releasing a record nobody holds is not an error. It is the state the caller wanted.
st, se := s.settings.LoadTx(ctx, tx)
if se != nil {
return se
}
out, se = s.activityTx(ctx, tx, entityType, entityID, actorID, role, st)
return se
}
if e != nil {
return e
}
if holder != actorID && !isManager {
return ErrNotYours
}
if _, e := tx.Exec(ctx, `
UPDATE work_claims SET released_at = now(), released_by = $1
WHERE entity_type = $2 AND entity_id = $3 AND released_at IS NULL`,
actorID, entityType, entityID); e != nil {
return e
}
if s.aud != nil && holder != actorID {
body, _ := marshalJSON(map[string]any{
"entity_type": entityType, "entity_id": entityID.String(),
"released_holder": holder.String(), "released_by": actorID.String(),
})
if e := s.aud.OnTx(ctx, tx, audit.Event{
Action: "work.claim.released_by_manager", ActorUserID: &actorID,
EntityType: entityType, EntityID: entityID.String(), NewValues: body,
IPAddress: ip, UserAgent: ua,
}); e != nil {
return e
}
}
st, se := s.settings.LoadTx(ctx, tx)
if se != nil {
return se
}
out, se = s.activityTx(ctx, tx, entityType, entityID, actorID, role, st)
return se
})
return out, err
}
// Touch records that the caller looked at a record, and returns the activity they are allowed to see. This is
// the call a detail screen makes on open: it is both the write and the read, so a page load is one request.
//
// IT IS AN UPSERT, NEVER AN APPEND. One row per person per record. Appending would generate a row per page
// view per recruiter per candidate, which is millions of rows a month describing something nobody reads
// twice, and it would put a browsing history of a named employee in a trivially reportable table. This is
// presence, not surveillance.
func (s *Service) Touch(ctx context.Context, entityType string, entityID uuid.UUID) (Activity, error) {
if s.db == nil {
return Activity{}, ErrNoDatabase
}
if !ValidEntityType(entityType) {
return Activity{}, ErrUnknownType
}
actor, ok := tenant.UserFromContext(ctx)
if !ok {
return Activity{}, tenant.ErrNoTenant
}
actorID, err := uuid.Parse(actor.UserID)
if err != nil {
return Activity{}, tenant.ErrNoTenant
}
var out Activity
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if e := assertExists(ctx, tx, entityType, entityID); e != nil {
return e
}
st, e := s.settings.LoadTx(ctx, tx)
if e != nil {
return e
}
// Presence is only recorded when the organization wants it. An organization that switched it off is
// asking not to be tracked, and writing the row anyway and merely hiding it would be a lie.
if st.ShowActivityPresence {
if _, e := tx.Exec(ctx, `
INSERT INTO work_touches (organization_id, entity_type, entity_id, user_id, last_seen_at)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, now())
ON CONFLICT (organization_id, entity_type, entity_id, user_id)
DO UPDATE SET last_seen_at = now()`, entityType, entityID, actorID); e != nil {
return e
}
}
out, e = s.activityTx(ctx, tx, entityType, entityID, actorID, security.Role(actor.Role), st)
return e
})
return out, err
}
// GetActivity reads without recording a visit.
func (s *Service) GetActivity(ctx context.Context, entityType string, entityID uuid.UUID) (Activity, error) {
if s.db == nil {
return Activity{}, ErrNoDatabase
}
if !ValidEntityType(entityType) {
return Activity{}, ErrUnknownType
}
actor, ok := tenant.UserFromContext(ctx)
if !ok {
return Activity{}, tenant.ErrNoTenant
}
actorID, err := uuid.Parse(actor.UserID)
if err != nil {
return Activity{}, tenant.ErrNoTenant
}
var out Activity
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if e := assertExists(ctx, tx, entityType, entityID); e != nil {
return e
}
st, e := s.settings.LoadTx(ctx, tx)
if e != nil {
return e
}
out, e = s.activityTx(ctx, tx, entityType, entityID, actorID, security.Role(actor.Role), st)
return e
})
return out, err
}
// MyClaims is the recruiter's own list, which is the other half of not double working: knowing what you
// yourself have open. It is never filtered by visibility, because these are the caller's own claims.
func (s *Service) MyClaims(ctx context.Context) ([]Claim, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
actor, ok := tenant.UserFromContext(ctx)
if !ok {
return nil, tenant.ErrNoTenant
}
actorID, err := uuid.Parse(actor.UserID)
if err != nil {
return nil, tenant.ErrNoTenant
}
out := make([]Claim, 0, 32)
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, e := tx.Query(ctx, `
SELECT c.id::text, c.entity_type, c.entity_id::text, c.user_id::text, coalesce(u.full_name,''),
c.activity, c.note, c.claimed_at, c.expires_at
FROM work_claims c LEFT JOIN users u ON u.id = c.user_id
WHERE c.user_id = $1 AND c.released_at IS NULL AND c.expires_at > now()
ORDER BY c.claimed_at DESC LIMIT 200`, actorID)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var c Claim
if e := rows.Scan(&c.ID, &c.EntityType, &c.EntityID, &c.UserID, &c.UserName,
&c.Activity, &c.Note, &c.ClaimedAt, &c.ExpiresAt); e != nil {
return e
}
c.Mine = true
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// activityTx assembles the answer and APPLIES THE VISIBILITY RULE. This is the one place that decides, so a
// new caller cannot reach the raw rows and forget.
func (s *Service) activityTx(ctx context.Context, tx pgx.Tx, entityType string, entityID, actorID uuid.UUID,
role security.Role, st orgsettings.Settings) (Activity, error) {
a := Activity{
EntityType: entityType,
EntityID: entityID.String(),
Touches: []Touch{},
Visible: security.CanSeeOthersWork(role, st.WorkVisibility),
ClaimRequired: st.ClaimRequired,
}
var c Claim
var released *time.Time
err := tx.QueryRow(ctx, `
SELECT c.id::text, c.user_id::text, coalesce(u.full_name,''), c.activity, c.note,
c.claimed_at, c.expires_at, c.released_at
FROM work_claims c LEFT JOIN users u ON u.id = c.user_id
WHERE c.entity_type = $1 AND c.entity_id = $2 AND c.released_at IS NULL AND c.expires_at > now()`,
entityType, entityID).Scan(&c.ID, &c.UserID, &c.UserName, &c.Activity, &c.Note,
&c.ClaimedAt, &c.ExpiresAt, &released)
switch {
case errors.Is(err, pgx.ErrNoRows):
// No live claim, nothing to show or hide.
case err != nil:
return Activity{}, err
default:
c.EntityType, c.EntityID = entityType, entityID.String()
c.Mine = c.UserID == actorID.String()
// A caller who may not see other people's work still sees their OWN claim. Hiding it would leave a
// recruiter unable to tell they already hold the record, which causes the very double work this is
// here to prevent.
if a.Visible || c.Mine {
a.Claim = &c
}
}
if st.ShowActivityPresence {
rows, e := tx.Query(ctx, `
SELECT t.user_id::text, coalesce(u.full_name,''), t.last_seen_at
FROM work_touches t LEFT JOIN users u ON u.id = t.user_id
WHERE t.entity_type = $1 AND t.entity_id = $2 AND t.last_seen_at > now() - interval '24 hours'
ORDER BY t.last_seen_at DESC LIMIT 25`, entityType, entityID)
if e != nil {
return Activity{}, e
}
defer rows.Close()
for rows.Next() {
var t Touch
if e := rows.Scan(&t.UserID, &t.UserName, &t.LastSeenAt); e != nil {
return Activity{}, e
}
t.Mine = t.UserID == actorID.String()
if a.Visible || t.Mine {
a.Touches = append(a.Touches, t)
}
}
if rows.Err() != nil {
return Activity{}, rows.Err()
}
}
// Desk ownership, where the entity type has it. Read from the EXISTING column rather than duplicated
// here, so migration 0047 stays the single source of truth for whose account this is.
if col, ok := ownerColumns[entityType]; ok && a.Visible {
var oid, oname *string
q := fmt.Sprintf(`SELECT e.%s::text, u.full_name FROM %s e
LEFT JOIN users u ON u.id = e.%s WHERE e.id = $1`, col, entityTables[entityType], col)
if e := tx.QueryRow(ctx, q, entityID).Scan(&oid, &oname); e != nil && !errors.Is(e, pgx.ErrNoRows) {
return Activity{}, e
}
if oid != nil {
a.DeskOwnerUserID = *oid
}
if oname != nil {
a.DeskOwnerName = *oname
}
}
return a, nil
}
// assertExists proves the record is in THIS tenant before anything is written about it. Without it a claim
// could be recorded against an identifier that does not exist, or one belonging to another organization,
// producing rows that reference nothing and a presence list for a record the caller cannot see.
func assertExists(ctx context.Context, tx pgx.Tx, entityType string, entityID uuid.UUID) error {
table, ok := entityTables[entityType]
if !ok {
return ErrUnknownType
}
// The table name comes from the closed map above and can never be caller supplied. The identifier is a
// bound parameter.
var one int
err := tx.QueryRow(ctx, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = $1`, table), entityID).Scan(&one)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
// EntityTypes returns the vocabulary, for the handler's error messages and for tests.
func EntityTypes() []string {
out := make([]string, 0, len(entityTables))
for k := range entityTables {
out = append(out, k)
}
// Sorted so the message is stable and a test can assert it.
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
func typeList() string { return strings.Join(EntityTypes(), ", ") }