create employee table
This commit is contained in:
@@ -66,13 +66,26 @@ func setStatus(t *testing.T, pool *pgxpool.Pool, userID, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
// seededUser is the demo user the fixture loads into the test database.
|
||||
// seededUser is the demo ADMINISTRATOR the fixture loads into the test
|
||||
// database.
|
||||
//
|
||||
// The role is now part of the question. The fixture used to hold one account,
|
||||
// so "the seeded user" and "the administrator" were the same row and ordering
|
||||
// by date was enough to find it. It holds two since the Employer console gained
|
||||
// somebody to sign in as, both created on the same seeded date, which left the
|
||||
// tiebreak to a deterministic UUID — and picked the employer. Tests that assert
|
||||
// an administrator's access were then asserting an employer's, and failed
|
||||
// exactly as they should have.
|
||||
//
|
||||
// So it asks for what it means. Ordering is kept beneath the filter for the
|
||||
// case of several administrators.
|
||||
func seededUser(t *testing.T, pool *pgxpool.Pool) (id, email string) {
|
||||
t.Helper()
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT id::text, email::text FROM users ORDER BY created_date, id LIMIT 1`).
|
||||
`SELECT id::text, email::text FROM users WHERE role = 'admin'
|
||||
ORDER BY created_date, id LIMIT 1`).
|
||||
Scan(&id, &email); err != nil {
|
||||
t.Fatalf("read the seeded user: %v", err)
|
||||
t.Fatalf("read the seeded administrator: %v", err)
|
||||
}
|
||||
return id, email
|
||||
}
|
||||
|
||||
195
go-api/internal/httpserver/employee_roles_test.go
Normal file
195
go-api/internal/httpserver/employee_roles_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Employee roles: what a worker declares they do.
|
||||
//
|
||||
// The properties here are the ones the conversational flow depends on and that
|
||||
// no amount of frontend testing can establish, because they are decided by a
|
||||
// SQL predicate and a derived column:
|
||||
//
|
||||
// THE OPERATOR IS NOT THE WORKER. An employer records a role for somebody
|
||||
// else. If the subject were derived from the session — as created_by
|
||||
// legitimately is — every role would be filed against whoever was signed in.
|
||||
//
|
||||
// A WORKER HOLDS MANY ROLES. There is deliberately no uniqueness on the
|
||||
// worker, so a second declaration is a second row and a role already marked
|
||||
// `placed` survives the worker declaring the same category again. The panel
|
||||
// promises exactly this in its review step: "The worker can hold more than one
|
||||
// role — recording this does not replace an existing one."
|
||||
|
||||
func createEmployeeRole(t *testing.T, r *rbac, act actor, body map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
got := r.as(act, "POST", "/api/v1/employee-roles", body)
|
||||
if got.code != http.StatusCreated {
|
||||
t.Fatalf("%s create employee role: %d (%v)", act.name, got.code, got.body)
|
||||
}
|
||||
return got.body["data"].(map[string]any)
|
||||
}
|
||||
|
||||
// The subject comes from the request; only the audit column comes from the
|
||||
// session. This is the test that fails if anyone ever derives worker_email the
|
||||
// way job-applications derives it for a talent caller.
|
||||
func TestEmployeeRoleRecordsTheWorkerNotTheOperator(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
rec := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": "someone-else@example.test",
|
||||
"worker_name": "Someone Else",
|
||||
"role_category": "Bartender",
|
||||
})
|
||||
|
||||
if rec["worker_email"] == r.empA.email {
|
||||
t.Fatal("the operator became the worker")
|
||||
}
|
||||
if got := rec["worker_email"]; got != "someone-else@example.test" {
|
||||
t.Errorf("worker_email = %v, want the worker's", got)
|
||||
}
|
||||
if got := rec["created_by"]; got != r.empA.id {
|
||||
t.Errorf("created_by = %v, want the operator %v", got, r.empA.id)
|
||||
}
|
||||
}
|
||||
|
||||
// created_by is ReadOnly in the descriptor, so a caller cannot attribute a role
|
||||
// to somebody else. The body's value is dropped, not honoured.
|
||||
func TestEmployeeRoleCreatedByIsNotClientSettable(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
rec := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": "worker@example.test", "role_category": "Server",
|
||||
"created_by": r.admin.id,
|
||||
})
|
||||
if got := rec["created_by"]; got != r.empA.id {
|
||||
t.Errorf("created_by = %v, want the caller %v — the body must not set it", got, r.empA.id)
|
||||
}
|
||||
}
|
||||
|
||||
// The promise the review step makes, tested against the database.
|
||||
func TestAWorkerHoldsManyRolesAndNoneReplaceAnother(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const worker = "many-roles@example.test"
|
||||
|
||||
first := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": worker, "worker_name": "Many Roles",
|
||||
"role_category": "Bartender", "status": "placed",
|
||||
})
|
||||
second := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": worker, "worker_name": "Many Roles", "role_category": "Server",
|
||||
})
|
||||
// The same category again while the first is still placed: a worker who
|
||||
// finished a Bartender placement and is seeking Bartender work again.
|
||||
third := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": worker, "worker_name": "Many Roles", "role_category": "Bartender",
|
||||
})
|
||||
|
||||
ids := map[string]bool{}
|
||||
for _, rec := range []map[string]any{first, second, third} {
|
||||
id := rec["id"].(string)
|
||||
if ids[id] {
|
||||
t.Fatalf("duplicate id %s — a role replaced another", id)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
|
||||
got := r.ids(t, r.empA, "/api/v1/employee-roles?worker_email="+worker)
|
||||
for id := range ids {
|
||||
if !got[id] {
|
||||
t.Errorf("role %s is missing — it was overwritten or filtered away", id)
|
||||
}
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Errorf("%d roles for one worker, want 3", len(got))
|
||||
}
|
||||
if first["status"] != "placed" {
|
||||
t.Errorf("the first role's status = %v, want placed to survive", first["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// Every field the conversation collects survives the round trip. Named from the
|
||||
// payload the panel actually sends, so a column the flow fills and the API drops
|
||||
// fails here rather than silently arriving empty.
|
||||
func TestEmployeeRoleKeepsEveryCollectedField(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
rec := createEmployeeRole(t, r, r.admin, map[string]any{
|
||||
"worker_email": "full@example.test", "worker_name": "Full Record",
|
||||
"role_category": "Picker", "experience_years": 3,
|
||||
"english_level": "native", "certifications": []string{"TIPS Certified"},
|
||||
"desired_pay_min": 30, "desired_pay_max": 40,
|
||||
"availability": []string{"Weekdays"}, "notes": "recorded by the panel",
|
||||
"status": "seeking",
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
want any
|
||||
}{
|
||||
{"role_category", "Picker"},
|
||||
{"experience_years", float64(3)},
|
||||
{"english_level", "native"},
|
||||
{"desired_pay_min", float64(30)},
|
||||
{"desired_pay_max", float64(40)},
|
||||
{"notes", "recorded by the panel"},
|
||||
{"status", "seeking"},
|
||||
} {
|
||||
if got := rec[tc.field]; got != tc.want {
|
||||
t.Errorf("%s = %#v, want %#v", tc.field, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
field string
|
||||
want string
|
||||
}{{"certifications", "TIPS Certified"}, {"availability", "Weekdays"}} {
|
||||
list, _ := rec[tc.field].([]any)
|
||||
if len(list) != 1 || list[0] != tc.want {
|
||||
t.Errorf("%s = %#v, want [%q]", tc.field, rec[tc.field], tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-tenant isolation stands on its own: an ADMIN in another organization
|
||||
// gets 404, not 403, and never sees the row in a listing.
|
||||
func TestEmployeeRolesAreInvisibleAcrossOrganizations(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
rec := createEmployeeRole(t, r, r.admin, map[string]any{
|
||||
"worker_email": "inside@example.test", "role_category": "Bartender",
|
||||
})
|
||||
id := rec["id"].(string)
|
||||
|
||||
if got := r.as(r.outsider, "GET", "/api/v1/employee-roles/"+id, nil); got.code != http.StatusNotFound {
|
||||
t.Errorf("outside admin GET = %d, want 404", got.code)
|
||||
}
|
||||
if r.ids(t, r.outsider, "/api/v1/employee-roles")[id] {
|
||||
t.Error("a role leaked into another organization's listing")
|
||||
}
|
||||
if got := r.as(r.outsider, "PATCH", "/api/v1/employee-roles/"+id,
|
||||
map[string]any{"notes": "n"}); got.code != http.StatusNotFound {
|
||||
t.Errorf("outside admin PATCH = %d, want 404", got.code)
|
||||
}
|
||||
}
|
||||
|
||||
// A talent caller reads only their own declared roles, and cannot create.
|
||||
func TestTalentSeesOnlyItsOwnEmployeeRoles(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
mine := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": r.talA.email, "role_category": "Bartender",
|
||||
})
|
||||
theirs := createEmployeeRole(t, r, r.empA, map[string]any{
|
||||
"worker_email": r.talB.email, "role_category": "Server",
|
||||
})
|
||||
|
||||
seen := r.ids(t, r.talA, "/api/v1/employee-roles")
|
||||
if !seen[mine["id"].(string)] {
|
||||
t.Error("talent cannot see its own declared role")
|
||||
}
|
||||
if seen[theirs["id"].(string)] {
|
||||
t.Error("talent A can see talent B's declared role")
|
||||
}
|
||||
if got := r.as(r.talA, "GET", "/api/v1/employee-roles/"+theirs["id"].(string), nil); got.code != http.StatusNotFound {
|
||||
t.Errorf("GET another talent's role = %d, want 404 — absent, not refused", got.code)
|
||||
}
|
||||
}
|
||||
89
go-api/internal/httpserver/worker_identity_test.go
Normal file
89
go-api/internal/httpserver/worker_identity_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Who counts as the same person.
|
||||
//
|
||||
// The rule is the schema's and it is worth stating plainly, because the whole
|
||||
// duplicate question turns on it: `worker_profiles` carries
|
||||
// UNIQUE (org_id, email) and `email` is `citext`. So identity is the pair
|
||||
// (organization, email), compared case-insensitively, and `full_name` carries
|
||||
// NO uniqueness at all — an organization may employ any number of people with
|
||||
// the same name, and they are different people.
|
||||
//
|
||||
// These are database guarantees rather than application checks, which is what
|
||||
// makes them hold under concurrency: two simultaneous creates of the same
|
||||
// identity cannot both win, whatever the callers checked first.
|
||||
|
||||
func createWorker(t *testing.T, r *rbac, act actor, name, email string) response {
|
||||
t.Helper()
|
||||
return r.as(act, "POST", "/api/v1/worker-profiles", map[string]any{
|
||||
"full_name": name, "email": email,
|
||||
})
|
||||
}
|
||||
|
||||
// A name is not an identity. Two people who share one are two records.
|
||||
func TestWorkersMayShareAName(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const shared = "Shared Name"
|
||||
|
||||
first := createWorker(t, r, r.admin, shared, "shared-name-1@example.test")
|
||||
second := createWorker(t, r, r.admin, shared, "shared-name-2@example.test")
|
||||
|
||||
for i, got := range []response{first, second} {
|
||||
if got.code != http.StatusCreated {
|
||||
t.Fatalf("create %d: %d (%v) — sharing a name must not block creation", i+1, got.code, got.body)
|
||||
}
|
||||
}
|
||||
a := first.body["data"].(map[string]any)
|
||||
b := second.body["data"].(map[string]any)
|
||||
if a["id"] == b["id"] {
|
||||
t.Fatal("two people sharing a name collapsed into one record")
|
||||
}
|
||||
if a["email"] == b["email"] {
|
||||
t.Error("the second worker took the first one's email")
|
||||
}
|
||||
}
|
||||
|
||||
// The same identity cannot be created twice, whoever it claims to be, and the
|
||||
// refusal is a conflict a caller can act on rather than a 500.
|
||||
func TestTheSameIdentityCannotBeCreatedTwice(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const email = "one-identity@example.test"
|
||||
|
||||
if got := createWorker(t, r, r.admin, "Person One", email); got.code != http.StatusCreated {
|
||||
t.Fatalf("first create: %d (%v)", got.code, got.body)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, who, email string }{
|
||||
{"a different name on the same email", "Person Two", email},
|
||||
{"the same email in another case", "Person Three", "ONE-IDENTITY@EXAMPLE.TEST"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := createWorker(t, r, r.admin, tc.who, tc.email)
|
||||
if got.code != http.StatusConflict {
|
||||
t.Errorf("= %d, want 409 — the identity is already taken", got.code)
|
||||
}
|
||||
if got.errCode(t) != "conflict" {
|
||||
t.Errorf("error code = %q, want conflict", got.errCode(t))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The identity is scoped to the organization, so the same email in another
|
||||
// tenant is another person and is allowed.
|
||||
func TestTheSameEmailInAnotherOrganizationIsAnotherPerson(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const email = "cross-tenant-identity@example.test"
|
||||
|
||||
if got := createWorker(t, r, r.admin, "Inside", email); got.code != http.StatusCreated {
|
||||
t.Fatalf("create inside: %d (%v)", got.code, got.body)
|
||||
}
|
||||
if got := createWorker(t, r, r.outsider, "Outside", email); got.code != http.StatusCreated {
|
||||
t.Errorf("create in another organization = %d, want 201 — identity is (org, email)", got.code)
|
||||
}
|
||||
}
|
||||
175
go-api/internal/httpserver/worker_with_role_test.go
Normal file
175
go-api/internal/httpserver/worker_with_role_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package httpserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Recording a NEW person and their first declared role, atomically.
|
||||
//
|
||||
// The flow this endpoint exists for is a CREATION: HR is adding somebody the
|
||||
// organization does not have yet. So the properties under test are about
|
||||
// creation, not lookup — no worker id is accepted, no name is searched, and the
|
||||
// email the caller states is the identity the row is keyed on.
|
||||
|
||||
func createWorkerWithRole(t *testing.T, r *rbac, act actor, body map[string]any) response {
|
||||
t.Helper()
|
||||
return r.as(act, "POST", "/api/v1/worker-profiles/with-role", body)
|
||||
}
|
||||
|
||||
// The happy path, and the two records it must leave behind.
|
||||
func TestCreateWorkerWithRoleCreatesBoth(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const email = "new-person@example.test"
|
||||
|
||||
got := createWorkerWithRole(t, r, r.empA, map[string]any{
|
||||
"full_name": "New Person", "email": email,
|
||||
"role": map[string]any{
|
||||
"role_category": "Bartender", "experience_years": 3,
|
||||
"english_level": "fluent", "certifications": []string{"A Certification"},
|
||||
"desired_pay_min": 30, "desired_pay_max": 40,
|
||||
"availability": []string{"Weekdays"}, "notes": "recorded by the panel",
|
||||
},
|
||||
})
|
||||
if got.code != http.StatusCreated {
|
||||
t.Fatalf("= %d, want 201 (%v)", got.code, got.body)
|
||||
}
|
||||
|
||||
data := got.body["data"].(map[string]any)
|
||||
worker := data["worker"].(map[string]any)
|
||||
role := data["role"].(map[string]any)
|
||||
|
||||
if worker["id"] == nil || worker["id"] == "" {
|
||||
t.Fatal("no worker id came back")
|
||||
}
|
||||
// The whole point: the role points at the worker this call created.
|
||||
if role["worker_profile_id"] != worker["id"] {
|
||||
t.Errorf("role.worker_profile_id = %v, want the new worker %v", role["worker_profile_id"], worker["id"])
|
||||
}
|
||||
if role["worker_email"] != worker["email"] {
|
||||
t.Errorf("role.worker_email = %v, want %v", role["worker_email"], worker["email"])
|
||||
}
|
||||
// The operator is the author, never the subject.
|
||||
if role["created_by"] != r.empA.id {
|
||||
t.Errorf("created_by = %v, want the operator %v", role["created_by"], r.empA.id)
|
||||
}
|
||||
if worker["email"] == r.empA.email {
|
||||
t.Fatal("the operator became the worker")
|
||||
}
|
||||
// The role's own fields survived, and did not land on the worker.
|
||||
if role["role_category"] != "Bartender" {
|
||||
t.Errorf("role_category = %v", role["role_category"])
|
||||
}
|
||||
if _, leaked := worker["role_category"]; leaked {
|
||||
t.Error("a role field landed on the worker record")
|
||||
}
|
||||
|
||||
// Both are readable afterwards, under the caller's own org predicate.
|
||||
if !r.ids(t, r.empA, "/api/v1/worker-profiles")[worker["id"].(string)] {
|
||||
t.Error("the new worker is missing from the worker listing")
|
||||
}
|
||||
if !r.ids(t, r.empA, "/api/v1/employee-roles")[role["id"].(string)] {
|
||||
t.Error("the new role is missing from the role listing")
|
||||
}
|
||||
}
|
||||
|
||||
// A name is not an identity: same name, different emails, two people.
|
||||
func TestCreateWorkerWithRoleAllowsARepeatedName(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const name = "Repeated Name"
|
||||
|
||||
first := createWorkerWithRole(t, r, r.admin, map[string]any{
|
||||
"full_name": name, "email": "repeat-1@example.test",
|
||||
"role": map[string]any{"role_category": "Server"},
|
||||
})
|
||||
second := createWorkerWithRole(t, r, r.admin, map[string]any{
|
||||
"full_name": name, "email": "repeat-2@example.test",
|
||||
"role": map[string]any{"role_category": "Chef"},
|
||||
})
|
||||
|
||||
for i, got := range []response{first, second} {
|
||||
if got.code != http.StatusCreated {
|
||||
t.Fatalf("create %d = %d (%v) — a shared name must not block creation", i+1, got.code, got.body)
|
||||
}
|
||||
}
|
||||
a := first.body["data"].(map[string]any)["worker"].(map[string]any)
|
||||
b := second.body["data"].(map[string]any)["worker"].(map[string]any)
|
||||
if a["id"] == b["id"] {
|
||||
t.Fatal("two people sharing a name collapsed into one record")
|
||||
}
|
||||
}
|
||||
|
||||
// The identity is the email, and the database decides. A repeat is refused and
|
||||
// leaves NOTHING behind — no worker, no role.
|
||||
func TestCreateWorkerWithRoleRollsBackOnDuplicateIdentity(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
const email = "taken-identity@example.test"
|
||||
|
||||
if got := createWorkerWithRole(t, r, r.admin, map[string]any{
|
||||
"full_name": "First Person", "email": email,
|
||||
"role": map[string]any{"role_category": "Server"},
|
||||
}); got.code != http.StatusCreated {
|
||||
t.Fatalf("first create: %d (%v)", got.code, got.body)
|
||||
}
|
||||
|
||||
before := len(r.ids(t, r.admin, "/api/v1/employee-roles"))
|
||||
|
||||
got := createWorkerWithRole(t, r, r.admin, map[string]any{
|
||||
"full_name": "Second Person", "email": email,
|
||||
"role": map[string]any{"role_category": "Chef"},
|
||||
})
|
||||
if got.code != http.StatusConflict {
|
||||
t.Fatalf("duplicate identity = %d, want 409 (%v)", got.code, got.body)
|
||||
}
|
||||
if after := len(r.ids(t, r.admin, "/api/v1/employee-roles")); after != before {
|
||||
t.Errorf("%d roles after a refused create, want %d — the transaction did not roll back", after, before)
|
||||
}
|
||||
}
|
||||
|
||||
// A role cannot be recorded for nobody, and an email is never invented for a
|
||||
// name that arrived without one.
|
||||
func TestCreateWorkerWithRoleRequiresBothNameAndEmail(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"no email", map[string]any{"full_name": "Nameless Email", "role": map[string]any{"role_category": "Server"}}},
|
||||
{"blank email", map[string]any{"full_name": "Blank", "email": " ", "role": map[string]any{"role_category": "Server"}}},
|
||||
{"no name", map[string]any{"email": "no-name@example.test", "role": map[string]any{"role_category": "Server"}}},
|
||||
{"neither", map[string]any{"role": map[string]any{"role_category": "Server"}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := createWorkerWithRole(t, r, r.admin, tc.body)
|
||||
if got.code == http.StatusCreated {
|
||||
t.Fatalf("accepted a worker with %s: %v", tc.name, got.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Talent cannot record workers, and another organization cannot see the ones
|
||||
// this one records.
|
||||
func TestCreateWorkerWithRoleIsScopedAndAuthorized(t *testing.T) {
|
||||
r := newRBAC(t)
|
||||
|
||||
if got := createWorkerWithRole(t, r, r.talA, map[string]any{
|
||||
"full_name": "Not Allowed", "email": "not-allowed@example.test",
|
||||
"role": map[string]any{"role_category": "Server"},
|
||||
}); got.code != http.StatusForbidden {
|
||||
t.Errorf("talent create = %d, want 403", got.code)
|
||||
}
|
||||
|
||||
made := createWorkerWithRole(t, r, r.admin, map[string]any{
|
||||
"full_name": "Inside Only", "email": "inside-only@example.test",
|
||||
"role": map[string]any{"role_category": "Server"},
|
||||
})
|
||||
if made.code != http.StatusCreated {
|
||||
t.Fatalf("create: %d (%v)", made.code, made.body)
|
||||
}
|
||||
roleID := made.body["data"].(map[string]any)["role"].(map[string]any)["id"].(string)
|
||||
if r.ids(t, r.outsider, "/api/v1/employee-roles")[roleID] {
|
||||
t.Error("a role leaked into another organization")
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,50 @@ func errUnregisteredResource(path string) error { return unregisteredResourceErr
|
||||
func (s *Server) routeWorkflows(mux *http.ServeMux) int {
|
||||
mux.HandleFunc("POST /api/v1/job-applications/{id}/hire", s.handleHire)
|
||||
mux.HandleFunc("POST /api/v1/job-postings/{id}/assignments", s.handleAssign)
|
||||
return 2
|
||||
mux.HandleFunc("POST /api/v1/worker-profiles/with-role", s.handleCreateWorkerWithRole)
|
||||
return 3
|
||||
}
|
||||
|
||||
// handleCreateWorkerWithRole records a NEW person and their first declared role
|
||||
// in one transaction.
|
||||
//
|
||||
// Under `worker-profiles` rather than `employee-roles` because the worker is
|
||||
// what the request creates; the role comes with it. A more specific literal
|
||||
// than the generated `POST /api/v1/worker-profiles`, so the mux prefers it and
|
||||
// neither route shadows the other.
|
||||
//
|
||||
// This is a CREATION flow. It takes a name and an email and never a worker id,
|
||||
// and nothing in it searches for an existing person — an organization may
|
||||
// employ many people who share a name, so a name cannot select anybody.
|
||||
// Recording a second role for someone who already exists is
|
||||
// POST /api/v1/employee-roles, unchanged.
|
||||
func (s *Server) handleCreateWorkerWithRole(w http.ResponseWriter, r *http.Request) {
|
||||
ident, ok := s.authorizeAll(w, r,
|
||||
requirement{"worker-profiles", domain.OpCreate},
|
||||
requirement{"employee-roles", domain.OpCreate},
|
||||
)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := decodeBody(r)
|
||||
if err != nil {
|
||||
writeError(w, s.log, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.workflows.CreateWorkerWithRole(r.Context(), ident, body)
|
||||
if err != nil {
|
||||
writeError(w, s.log, err)
|
||||
return
|
||||
}
|
||||
|
||||
/* The worker's identity is not logged: an email is the person, and §10 puts
|
||||
record content at DEBUG behind a per-tenant flag rather than at INFO. */
|
||||
s.log.Info("worker recorded with a declared role", "user_id", ident.UserID,
|
||||
"worker_profile_id", result.Worker["id"], "employee_role_id", result.Role["id"])
|
||||
|
||||
writeJSON(w, http.StatusCreated, envelope{Data: result})
|
||||
}
|
||||
|
||||
// handleHire moves an application to `hired` and creates the staff record in
|
||||
|
||||
Reference in New Issue
Block a user