Stage 7 · Master
Phase 13 — Staff Service
Staff Management
Encode the staff lifecycle as an explicit transition table and gate hire and termination behind organization role, not client convention.
A Status Column Is Not a Lifecycle
Complaint Service already made the case for a transition table over scattered ifs, and this is the same technique. The difference is what an illegal move costs. A complaint dragged backwards through its workflow is an audit annoyance someone can correct; terminated quietly returning to active restores a badge, a login, and building access to someone who was removed for cause. That asymmetry is why staff has a genuinely terminal state — the one shape the complaint machine deliberately does not have, since a closed complaint can always be reopened.
| State | Meaning | Who may cause the transition |
|---|---|---|
| active | Currently rostered and eligible for shifts | Hire creates it directly |
| on_leave | Temporarily away, still employed | The staff member's own manager |
| suspended | Under review after an incident | org_admin or org_manager only |
| terminated | Employment ended, a terminal state | org_admin or org_manager only |
Rehiring a terminated staff member creates a new record with a new hire date. Reopening the old row would silently erase the audit trail of why employment ended, which the Complaint and Visitor phases may still reference.
Represent the Transition Table, Don't Scatter It
The domain package exposes behavior methods — PutOnLeave, Reactivate, Suspend, Terminate — instead of a public setter. Each method consults one map instead of duplicating an if/else chain per caller. Adding a fifth state later means editing one map, not auditing every handler that touches status.
package domain
import (
"errors"
"time"
)
type Role string
const (
RoleGuard Role = "guard"
RoleHousekeeping Role = "housekeeping"
RoleTechnician Role = "technician"
RoleOffice Role = "office"
)
type Status string
const (
StatusActive Status = "active"
StatusOnLeave Status = "on_leave"
StatusSuspended Status = "suspended"
StatusTerminated Status = "terminated"
)
var ErrInvalidTransition = errors.New("staff status transition is not allowed")
var transitions = map[Status][]Status{
StatusActive: {StatusOnLeave, StatusSuspended, StatusTerminated},
StatusOnLeave: {StatusActive, StatusTerminated},
StatusSuspended: {StatusActive, StatusTerminated},
StatusTerminated: {},
}
type Staff struct {
id string
organizationID string
fullName string
phone string
role Role
status Status
hiredAt time.Time
terminatedAt *time.Time
}
func Hire(id, organizationID, fullName, phone string, role Role, hiredAt time.Time) (*Staff, error) {
if organizationID == "" {
return nil, errors.New("organization id is required")
}
if fullName == "" {
return nil, errors.New("full name is required")
}
return &Staff{
id: id, organizationID: organizationID, fullName: fullName,
phone: phone, role: role, status: StatusActive, hiredAt: hiredAt,
}, nil
}
// Rehydrate reconstructs a Staff from stored primitives. Only the
// repository adapter calls it; application code always goes through Hire.
func Rehydrate(id, organizationID, fullName, phone string, role Role, status Status, hiredAt time.Time, terminatedAt *time.Time) *Staff {
return &Staff{id, organizationID, fullName, phone, role, status, hiredAt, terminatedAt}
}
func (s *Staff) move(next Status) error {
for _, candidate := range transitions[s.status] {
if candidate == next {
s.status = next
return nil
}
}
return ErrInvalidTransition
}
func (s *Staff) PutOnLeave() error { return s.move(StatusOnLeave) }
func (s *Staff) Reactivate() error { return s.move(StatusActive) }
func (s *Staff) Suspend() error { return s.move(StatusSuspended) }
func (s *Staff) Terminate(at time.Time) error {
if err := s.move(StatusTerminated); err != nil {
return err
}
s.terminatedAt = &at
return nil
}
func (s *Staff) ID() string { return s.id }
func (s *Staff) OrganizationID() string { return s.organizationID }
func (s *Staff) FullName() string { return s.fullName }
func (s *Staff) Phone() string { return s.phone }
func (s *Staff) Role() Role { return s.role }
func (s *Staff) Status() Status { return s.status }
func (s *Staff) HiredAt() time.Time { return s.hiredAt }move() is the only place a status field is assigned. Every public transition method funnels through it, so a new state only ever needs one new map entry and one new method.
Let PostgreSQL Reject What the Domain Already Rejects
CREATE TYPE staff_role AS ENUM ('guard', 'housekeeping', 'technician', 'office');
CREATE TYPE staff_status AS ENUM ('active', 'on_leave', 'suspended', 'terminated');
CREATE TABLE staff (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL, -- logical tenant ID owned by organization-service
full_name text NOT NULL,
phone text NOT NULL,
role staff_role NOT NULL,
status staff_status NOT NULL DEFAULT 'active',
hired_at timestamptz NOT NULL DEFAULT now(),
terminated_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (organization_id, phone)
);
CREATE INDEX idx_staff_org_status ON staff (organization_id, status);The enum types are a second line of defense: even a bug that bypasses the domain map cannot write a status PostgreSQL has never heard of. The unique constraint on (organization_id, phone) keeps duplicate registration a tenant-local rule, not a global one.
package postgresadapter
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/staff-service/internal/domain"
)
var ErrNotFound = errors.New("staff member not found")
var ErrDuplicatePhone = errors.New("phone number already registered in this organization")
type StaffRepository struct {
pool *pgxpool.Pool
}
func NewStaffRepository(pool *pgxpool.Pool) (*StaffRepository, error) {
if pool == nil {
return nil, errors.New("pgx pool is required")
}
return &StaffRepository{pool: pool}, nil
}
func (r *StaffRepository) Insert(ctx context.Context, s *domain.Staff) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO staff (id, organization_id, full_name, phone, role, status, hired_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
s.ID(), s.OrganizationID(), s.FullName(), s.Phone(), s.Role(), s.Status(), s.HiredAt(),
)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrDuplicatePhone
}
return err
}
func (r *StaffRepository) FindByID(ctx context.Context, organizationID, staffID string) (*domain.Staff, error) {
row := r.pool.QueryRow(ctx, `
SELECT id, organization_id, full_name, phone, role, status, hired_at, terminated_at
FROM staff
WHERE organization_id = $1 AND id = $2`, organizationID, staffID)
var id, orgID, fullName, phone, role, status string
var hiredAt time.Time
var terminatedAt *time.Time
if err := row.Scan(&id, &orgID, &fullName, &phone, &role, &status, &hiredAt, &terminatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return domain.Rehydrate(id, orgID, fullName, phone, domain.Role(role), domain.Status(status), hiredAt, terminatedAt), nil
}
func (r *StaffRepository) UpdateStatus(ctx context.Context, organizationID, staffID string, status domain.Status, terminatedAt *time.Time) error {
tag, err := r.pool.Exec(ctx, `
UPDATE staff SET status = $1, terminated_at = $2, updated_at = now()
WHERE organization_id = $3 AND id = $4`, status, terminatedAt, organizationID, staffID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}FindByID and UpdateStatus both filter by organization_id in the WHERE clause, never as a post-query check. A staffID that belongs to another organization simply does not match any row — the query cannot return another tenant's staff member by accident.
The Client Cannot Be Trusted to Hide the Terminate Button
Hiding a 'Terminate' button in the residents' portal is a UI convenience, not a security control. The handler re-derives the caller's roles from the verified JWT claims already attached to the request context by the Authentication phase's middleware, and rejects the call before the use case runs.
package httpadapter
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/hoa-platform/backend/pkg/authctx"
"github.com/hoa-platform/backend/services/staff-service/internal/application"
)
type StaffHandler struct {
hire *application.HireStaff
terminate *application.TerminateStaff
}
func NewStaffHandler(hire *application.HireStaff, terminate *application.TerminateStaff) (*StaffHandler, error) {
if hire == nil || terminate == nil {
return nil, errors.New("hire and terminate use cases are required")
}
return &StaffHandler{hire: hire, terminate: terminate}, nil
}
type hireStaffRequest struct {
FullName string `json:"full_name" binding:"required,min=2,max=120"`
Phone string `json:"phone" binding:"required,e164"`
Role string `json:"role" binding:"required,oneof=guard housekeeping technician office"`
}
func (h *StaffHandler) Hire(c *gin.Context) {
claims, err := authctx.FromContext(c.Request.Context())
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": "unauthenticated"})
return
}
if !claims.HasAnyRole("org_admin", "org_manager") {
c.JSON(http.StatusForbidden, gin.H{"code": "forbidden_role"})
return
}
var req hireStaffRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": "validation_failed"})
return
}
staff, err := h.hire.Execute(c.Request.Context(), application.HireCommand{
TenantID: claims.TenantID,
FullName: req.FullName,
Phone: req.Phone,
Role: req.Role,
})
switch {
case errors.Is(err, application.ErrDuplicatePhone):
c.JSON(http.StatusConflict, gin.H{"code": "phone_already_registered"})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"code": "internal_error"})
return
}
c.JSON(http.StatusCreated, staff)
}
func (h *StaffHandler) Terminate(c *gin.Context) {
claims, err := authctx.FromContext(c.Request.Context())
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"code": "unauthenticated"})
return
}
if !claims.HasAnyRole("org_admin", "org_manager") {
c.JSON(http.StatusForbidden, gin.H{"code": "forbidden_role"})
return
}
err = h.terminate.Execute(c.Request.Context(), application.TerminateCommand{
TenantID: claims.TenantID,
StaffID: c.Param("staffID"),
At: time.Now().UTC(),
})
switch {
case errors.Is(err, application.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"code": "staff_not_found"})
return
case errors.Is(err, application.ErrInvalidTransition):
c.JSON(http.StatusConflict, gin.H{"code": "invalid_transition"})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"code": "internal_error"})
return
}
c.Status(http.StatusNoContent)
}Terminate always resolves TenantID from the verified claim, never from a request body field. A caller cannot terminate a staff member in another organization by simply changing a JSON field, because the repository query below never sees an organization_id it wasn't given by the server.
When organization B's admin requests organization A's staffID, the repository query returns no row and the handler answers 404. Answering 403 would confirm the ID exists in another tenant at all — a small information leak that a 404 avoids entirely.
Prove Isolation and the Guard Before Moving On
Applied exercise
Add a suspension reason without breaking the transition map
Compliance asks that every Suspend call record a short reason string that shows up later in an audit export.
- Add a
reasonparameter toSuspendwithout adding a public setter for status. - Decide where the reason is stored: a new column on
staff, or a separatestaff_status_historytable. - Update the migration and the repository method that persists a suspension.
- Write one test proving
Suspendstill rejects a call fromterminated.
Deliverable
A modified staff.go, a new or altered migration file, and a passing test for the rejected transition.
Completion checks
- The transition map itself is untouched — only Suspend's signature and persistence changed.
- The reason is queryable per staff member, not only visible in application logs.
- Suspend from terminated still returns ErrInvalidTransition.
Why does UpdateStatus check `tag.RowsAffected() == 0` instead of trusting the caller-supplied staffID?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.