Stage 7 · Master
Phase 10 — Complaint Service
Status Management
Guard every complaint transition with both the state machine and role-aware authorization, then compute category-and-severity SLA deadlines that a watcher can breach-detect automatically.
What does tenant scoping not solve for complaint transitions?
By now every repository in complaint-service already scopes queries by org_id, and that remains non-negotiable for complaints. But tenant scoping only answers 'does this complaint belong to your organization?' It does not answer 'are you allowed to perform this transition?' Payment taught a different security lesson last module: signed webhooks proved the caller was the payment provider. Complaint transitions are about a legitimate tenant user trying to do something their role should not do, such as a resident resolving their own ticket or one staff member closing a complaint without resident confirmation.
Treat those as two independent authorization layers. First, the repository loads the complaint inside actor.OrgID so cross-tenant access dies at the data boundary. Second, the service checks whether the actor's role and identity allow the requested edge. If you conflate those checks, you eventually ship a system where a request is 'authorized' simply because it stayed inside the right tenant, which is not enough for operational accountability.
The gateway proves who the caller is and which tenant they belong to. The complaint service still has to decide whether that caller may acknowledge, resolve, close, or reopen a ticket.
How should Transition enforce legal edges and role policy together?
The service method does three things in a fixed order: load the complaint within the actor's org, reject illegal graph moves with CanTransition, and then apply role policy for the specific destination status. STAFF and ORG_ADMIN may move work into acknowledged, in_progress, or resolved. Only the resident who raised the complaint, or an ORG_ADMIN acting after the grace policy, may close a resolved complaint. Reopen is likewise constrained because reopened work re-enters the queue and affects SLA again.
package complaint
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
type Role string
const (
RoleSuperAdmin Role = "SUPER_ADMIN"
RoleOrgAdmin Role = "ORG_ADMIN"
RoleStaff Role = "STAFF"
RoleResident Role = "RESIDENT"
RoleSecurityGuard Role = "SECURITY_GUARD"
)
type Actor struct {
OrgID uuid.UUID
UserID uuid.UUID
Role Role
}
type Complaint struct {
ID uuid.UUID
OrgID uuid.UUID
FlatID uuid.UUID
Category Category
Severity Severity
Status Status
RaisedBy uuid.UUID
DueAt *time.Time
SLABreached bool
}
var (
ErrComplaintNotFound = errors.New("complaint: not found")
ErrInvalidTransition = errors.New("complaint: invalid transition")
ErrTransitionForbidden = errors.New("complaint: actor cannot perform transition")
)
type TransitionRepository interface {
GetComplaintForUpdate(ctx context.Context, orgID, complaintID uuid.UUID) (Complaint, error)
UpdateComplaintStatus(ctx context.Context, orgID, complaintID uuid.UUID, to Status, dueAt *time.Time, slaBreached bool) error
}
type PolicyResolver interface {
DueAt(ctx context.Context, orgID uuid.UUID, category Category, severity Severity, status Status, now time.Time) (*time.Time, error)
}
type TransitionService struct {
repo TransitionRepository
policies PolicyResolver
now func() time.Time
}
func NewTransitionService(repo TransitionRepository, policies PolicyResolver) *TransitionService {
return &TransitionService{
repo: repo,
policies: policies,
now: time.Now,
}
}
func (s *TransitionService) Transition(ctx context.Context, complaintID uuid.UUID, to Status, actor Actor) error {
complaint, err := s.repo.GetComplaintForUpdate(ctx, actor.OrgID, complaintID)
if err != nil {
return err
}
if !CanTransition(complaint.Status, to) {
return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, complaint.Status, to)
}
if err := authorizeTransition(complaint, to, actor); err != nil {
return err
}
dueAt, err := s.policies.DueAt(ctx, actor.OrgID, complaint.Category, complaint.Severity, to, s.now())
if err != nil {
return err
}
return s.repo.UpdateComplaintStatus(ctx, actor.OrgID, complaintID, to, dueAt, false)
}
func authorizeTransition(complaint Complaint, to Status, actor Actor) error {
switch to {
case StatusAcknowledged, StatusInProgress, StatusResolved:
if hasOperationsRole(actor.Role) {
return nil
}
return ErrTransitionForbidden
case StatusClosed:
if complaint.Status != StatusResolved {
return ErrTransitionForbidden
}
if actor.Role == RoleOrgAdmin || actor.UserID == complaint.RaisedBy {
return nil
}
return ErrTransitionForbidden
case StatusReopened:
if actor.Role == RoleOrgAdmin || actor.UserID == complaint.RaisedBy {
return nil
}
return ErrTransitionForbidden
default:
return nil
}
}
func hasOperationsRole(role Role) bool {
return role == RoleStaff || role == RoleOrgAdmin
}The important separation is visible in the method signature: actor.OrgID scopes the read, and authorizeTransition scopes the action. One cannot replace the other.
How do category and severity become due_at timestamps instead of hard-coded constants?
Status changes become operationally meaningful when they carry a deadline. complaint_sla_policies stores acknowledge and resolve windows per org, category, and severity. That lets one society declare critical security incidents must be acknowledged in two hours and resolved within twenty-four, while moderate plumbing can wait longer. due_at is then computed when a complaint enters a tracked state: open or reopened starts the acknowledgement clock, acknowledged or in_progress starts the resolution clock, and resolved or closed clears it.
The failure mode is deadline drift hidden in code. If one handler adds two hours for security and another adds one hour because someone duplicated a constant, operations loses trust in the dashboard. Centralizing policy in a table makes changes auditable, tenant-specific, and deploy-independent. It also keeps severity visible in data rather than implicit in route choice.
CREATE TABLE complaint_sla_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
category TEXT NOT NULL CHECK (category IN ('plumbing', 'electrical', 'security', 'noise', 'common_area', 'parking')),
severity TEXT NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
acknowledge_within INTERVAL NOT NULL,
resolve_within INTERVAL NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, category, severity)
);
ALTER TABLE complaints
ADD COLUMN severity TEXT NOT NULL DEFAULT 'medium' CHECK (severity IN ('low', 'medium', 'high', 'critical')),
ADD COLUMN due_at TIMESTAMPTZ,
ADD COLUMN sla_breached BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE complaints
ALTER COLUMN severity DROP DEFAULT;
CREATE INDEX complaints_due_at_pending_idx
ON complaints (org_id, due_at)
WHERE due_at IS NOT NULL
AND sla_breached = false
AND status IN ('open', 'acknowledged', 'in_progress', 'reopened');The partial index keeps the sweep query focused on the rows that can actually breach.
ALTER TABLE complaints
DROP COLUMN IF EXISTS sla_breached,
DROP COLUMN IF EXISTS due_at,
DROP COLUMN IF EXISTS severity;
DROP TABLE IF EXISTS complaint_sla_policies;package complaint
import (
"context"
"time"
"github.com/google/uuid"
)
type Severity string
const (
SeverityLow Severity = "low"
SeverityMedium Severity = "medium"
SeverityHigh Severity = "high"
SeverityCritical Severity = "critical"
)
type SLAPolicy struct {
OrgID uuid.UUID
Category Category
Severity Severity
AcknowledgeWithin time.Duration
ResolveWithin time.Duration
}
type PolicyRepository interface {
LookupPolicy(ctx context.Context, orgID uuid.UUID, category Category, severity Severity) (SLAPolicy, error)
}
type SweepRepository interface {
FindBreachedComplaints(ctx context.Context, now time.Time, limit int) ([]Complaint, error)
MarkSLABreached(ctx context.Context, orgID, complaintID uuid.UUID) error
}
type Publisher interface {
PublishSLABreached(ctx context.Context, complaint Complaint) error
}
type SLAService struct {
repo PolicyRepository
}
func NewSLAService(repo PolicyRepository) *SLAService {
return &SLAService{repo: repo}
}
func (s *SLAService) DueAt(ctx context.Context, orgID uuid.UUID, category Category, severity Severity, status Status, now time.Time) (*time.Time, error) {
policy, err := s.repo.LookupPolicy(ctx, orgID, category, severity)
if err != nil {
return nil, err
}
switch status {
case StatusOpen, StatusReopened:
due := now.Add(policy.AcknowledgeWithin)
return &due, nil
case StatusAcknowledged, StatusInProgress:
due := now.Add(policy.ResolveWithin)
return &due, nil
case StatusResolved, StatusClosed:
return nil, nil
default:
return nil, nil
}
}
const BreachSweepSQL = `SELECT id, org_id, flat_id, category, severity, status, raised_by, due_at, sla_breached
FROM complaints
WHERE due_at IS NOT NULL
AND due_at <= $1
AND status IN ('open', 'acknowledged', 'in_progress', 'reopened')
AND sla_breached = false
ORDER BY due_at ASC
FOR UPDATE SKIP LOCKED
LIMIT $2`
type Watcher struct {
repo SweepRepository
publisher Publisher
batchSize int
interval time.Duration
now func() time.Time
}
func NewWatcher(repo SweepRepository, publisher Publisher, interval time.Duration, batchSize int, now func() time.Time) *Watcher {
if now == nil {
now = time.Now
}
return &Watcher{
repo: repo,
publisher: publisher,
batchSize: batchSize,
interval: interval,
now: now,
}
}
func (w *Watcher) Run(ctx context.Context) error {
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
if err := w.Sweep(ctx); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func (w *Watcher) Sweep(ctx context.Context) error {
candidates, err := w.repo.FindBreachedComplaints(ctx, w.now(), w.batchSize)
if err != nil {
return err
}
for _, complaint := range candidates {
if err := w.repo.MarkSLABreached(ctx, complaint.OrgID, complaint.ID); err != nil {
return err
}
complaint.SLABreached = true
if err := w.publisher.PublishSLABreached(ctx, complaint); err != nil {
return err
}
}
return nil
}FOR UPDATE SKIP LOCKED keeps one sweep batch from blocking another worker inside the same database, and later we will harden it for multi-replica deployment.
Which failure modes matter once due_at becomes automated?
The obvious failure is false negatives: a complaint stays open past due_at but never flips sla_breached because the watcher only checks resolved tickets, or because due_at was cleared too early. The equally dangerous failure is false positives: due_at remains populated after resolved, so a finished complaint gets reported as a breach hours later. Both bugs are operationally expensive because managers will either ignore real breach alerts or distrust the entire board.
Role policy also interacts with SLA. If residents could move tickets into resolved, they could effectively stop the breach clock themselves and make staff performance look better than reality. That is why authorization belongs in the transition service, not in the UI. The API has to enforce it even when a client is buggy or malicious.
How do you verify role-gated transitions and breach scanning locally?
Applied exercise
Tune the watcher without turning Postgres into a cron server
A large society has thousands of open complaints. Operations wants near-real-time breach detection, but your first watcher poll interval hammers the database and scans the same tiny set of rows constantly.
- Measure how many rows the breach scan touches with the current partial index and batch size.
- Pick a new poll interval and batch size that keeps acknowledgement breaches visible quickly without doing wasteful sub-minute scans for slow-moving categories.
- Add or refine the supporting index so the sweep can stay on due_at and pending statuses only.
- Document what happens when a sweep finds more breached complaints than one batch can process.
Deliverable
A short tuning note with revised interval, batch size, index choice, and the reasoning that balances latency against database load.
Completion checks
- The scan remains bounded by an index on due_at plus pending-status rows.
- The chosen interval reflects real HOA urgency rather than arbitrary round numbers.
- The design explains how later batches catch backlog safely instead of silently skipping it.
A resident holds a valid JWT for org A. Why should Transition still reject that resident's attempt to move a complaint to resolved?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.