Stage 7 · Master
Phase 10 — Complaint Service
Assignment
Assign complaints to existing operations users inside the same organization, preserve reassignment history instead of overwriting it, and suggest the least-loaded assignee from the open queue.
Why does complaint assignment point at users before a dedicated staff entity exists?
The dedicated Staff and Vendor domain is intentionally deferred until two modules later, but real complaint operations cannot wait for future modeling purity. Today, the only reliable operators already in the platform are users with STAFF or ORG_ADMIN roles. So assignment targets user IDs now, within the same org, and later the staff/vendor module can extend the model with richer capabilities such as vendor contracts, external dispatch, and attendance. This is a good example of sequencing bounded contexts without blocking a necessary workflow.
The security rule is strict: the assignee must belong to the same org as the complaint and must hold an operations role. Rejecting a cross-org user matters even if the UUID exists, because otherwise one society's admin could intentionally or accidentally attach its ticket to another society's operator. That is a data-leak and workload-leak bug, not a mere validation bug.
How do you keep reassignment history instead of losing it on every new owner?
Do not add assignee_user_id directly to complaints and overwrite it forever. Reassignment history matters operationally: managers want to know whether a ticket bounced between security and maintenance, whether one admin keeps re-routing the same issue, and who held the work when an SLA breach occurred. complaint_assignments keeps one row per assignment period, with unassigned_at closed when ownership changes. One active assignment remains queryable, but the old records stay visible forever.
CREATE TABLE complaint_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
complaint_id UUID NOT NULL REFERENCES complaints(id) ON DELETE CASCADE,
org_id UUID NOT NULL,
assignee_user_id UUID NOT NULL,
assigned_by UUID NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
unassigned_at TIMESTAMPTZ,
CHECK (unassigned_at IS NULL OR unassigned_at >= assigned_at)
);
CREATE UNIQUE INDEX complaint_assignments_one_active_idx
ON complaint_assignments (complaint_id)
WHERE unassigned_at IS NULL;
CREATE INDEX complaint_assignments_org_assignee_history_idx
ON complaint_assignments (org_id, assignee_user_id, assigned_at DESC);
CREATE INDEX complaint_assignments_org_complaint_history_idx
ON complaint_assignments (org_id, complaint_id, assigned_at DESC);The partial unique index enforces 'at most one active assignment' without destroying old rows.
DROP TABLE IF EXISTS complaint_assignments;package complaint
import (
"context"
"errors"
"math"
"net/http"
"time"
"github.com/google/uuid"
)
type Assignment struct {
ComplaintID uuid.UUID
OrgID uuid.UUID
AssigneeUserID uuid.UUID
AssignedBy uuid.UUID
AssignedAt time.Time
UnassignedAt *time.Time
}
type UserRecord struct {
ID uuid.UUID
OrgID uuid.UUID
Role Role
Active bool
}
var (
ErrAssigneeNotInOrg = errors.New("complaint: assignee does not belong to actor org")
ErrAssigneeRoleInvalid = errors.New("complaint: assignee must be STAFF or ORG_ADMIN")
ErrAssigneeInactive = errors.New("complaint: assignee is inactive")
ErrNoEligibleAssignee = errors.New("complaint: no eligible assignee in org")
)
type AssignmentRepository interface {
ComplaintExists(ctx context.Context, orgID, complaintID uuid.UUID) (bool, error)
CloseActiveAssignment(ctx context.Context, orgID, complaintID uuid.UUID, at time.Time) error
InsertAssignment(ctx context.Context, assignment Assignment) error
OpenAssignmentCounts(ctx context.Context, orgID uuid.UUID) (map[uuid.UUID]int, error)
}
type UserDirectory interface {
Lookup(ctx context.Context, orgID, userID uuid.UUID) (UserRecord, error)
EligibleAssignees(ctx context.Context, orgID uuid.UUID) ([]UserRecord, error)
}
type AssignmentService struct {
repo AssignmentRepository
users UserDirectory
now func() time.Time
}
func NewAssignmentService(repo AssignmentRepository, users UserDirectory) *AssignmentService {
return &AssignmentService{
repo: repo,
users: users,
now: time.Now,
}
}
func (s *AssignmentService) Assign(ctx context.Context, complaintID, assigneeUserID uuid.UUID, actor Actor) error {
exists, err := s.repo.ComplaintExists(ctx, actor.OrgID, complaintID)
if err != nil {
return err
}
if !exists {
return ErrComplaintNotFound
}
assignee, err := s.users.Lookup(ctx, actor.OrgID, assigneeUserID)
if err != nil {
return err
}
if assignee.OrgID != actor.OrgID {
return ErrAssigneeNotInOrg
}
if !assignee.Active {
return ErrAssigneeInactive
}
if assignee.Role != RoleStaff && assignee.Role != RoleOrgAdmin {
return ErrAssigneeRoleInvalid
}
now := s.now()
if err := s.repo.CloseActiveAssignment(ctx, actor.OrgID, complaintID, now); err != nil {
return err
}
return s.repo.InsertAssignment(ctx, Assignment{
ComplaintID: complaintID,
OrgID: actor.OrgID,
AssigneeUserID: assigneeUserID,
AssignedBy: actor.UserID,
AssignedAt: now,
})
}
// Suggest cannot be one SQL query: the candidate list lives in user-service's
// database and the workload lives in ours. Each side answers the half it owns,
// and the merge happens in Go.
func (s *AssignmentService) Suggest(ctx context.Context, actor Actor) (uuid.UUID, error) {
candidates, err := s.users.EligibleAssignees(ctx, actor.OrgID)
if err != nil {
return uuid.Nil, err
}
if len(candidates) == 0 {
return uuid.Nil, ErrNoEligibleAssignee
}
load, err := s.repo.OpenAssignmentCounts(ctx, actor.OrgID)
if err != nil {
return uuid.Nil, err
}
best := uuid.Nil
bestLoad := math.MaxInt
for _, candidate := range candidates {
if !candidate.Active || (candidate.Role != RoleStaff && candidate.Role != RoleOrgAdmin) {
continue
}
open := load[candidate.ID]
// Ties break on the smaller UUID so repeated calls are stable.
if open < bestLoad || (open == bestLoad && candidate.ID.String() < best.String()) {
best, bestLoad = candidate.ID, open
}
}
if best == uuid.Nil {
return uuid.Nil, ErrNoEligibleAssignee
}
return best, nil
}
// OpenAssignmentCountsSQL touches only tables complaint-service owns. Counting
// the workload here — instead of joining user-service's users table — is what
// keeps the two databases independently deployable.
const OpenAssignmentCountsSQL = `SELECT ca.assignee_user_id, COUNT(*) AS open_complaints
FROM complaint_assignments ca
JOIN complaints c
ON c.id = ca.complaint_id
AND c.org_id = ca.org_id
WHERE ca.org_id = $1
AND ca.unassigned_at IS NULL
AND c.status IN ('open', 'acknowledged', 'in_progress', 'reopened')
GROUP BY ca.assignee_user_id`
func AssignmentErrorResponse(err error) (int, map[string]string) {
switch {
case errors.Is(err, ErrAssigneeNotInOrg), errors.Is(err, ErrAssigneeRoleInvalid), errors.Is(err, ErrAssigneeInactive):
return http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}
case errors.Is(err, ErrNoEligibleAssignee):
return http.StatusConflict, map[string]string{"error": err.Error()}
case errors.Is(err, ErrComplaintNotFound):
return http.StatusNotFound, map[string]string{"error": err.Error()}
default:
return http.StatusInternalServerError, map[string]string{"error": "internal server error"}
}
}Ties break on the smaller UUID so two callers get a deterministic winner instead of jittering between staff members.
Why can't the least-loaded query be one SQL statement?
The tempting implementation is a single query that joins users to complaint_assignments and returns the operator with the fewest open tickets. It cannot be written. The complaints schema deliberately stores raised_by without a foreign key because user accounts live in user-service's database — and the same boundary applies to assignees. Resident Service hit this first when it needed flats it did not own. The answer is the same shape — ask the owning service, join in Go — but the transport differs: this is an internal read between two services the platform deploys together, so it goes over the typed identity.v1 gRPC contract API Gateway introduced, not over the public REST edge.
So the work splits along ownership lines. user-service answers "who is eligible in this org right now" because it owns roles and the active flag, and it answers through IdentityDirectory — the same service auth-service already calls for login identity, extended with two reads rather than duplicated as a second directory API. complaint-service answers "how many open complaints does each of those user IDs currently hold" because it owns complaint_assignments. Neither query knows about the other's tables, and the merge is a loop over a few dozen candidates — small enough that pushing it into SQL would buy nothing but a coupling you cannot deploy around.
service IdentityDirectory {
rpc GetLoginIdentity(GetLoginIdentityRequest) returns (GetLoginIdentityResponse);
+ rpc GetMember(GetMemberRequest) returns (Member);
+ rpc ListAssignableMembers(ListAssignableMembersRequest) returns (ListAssignableMembersResponse);
}
+message GetMemberRequest {
+ string organization_id = 1;
+ string user_id = 2;
+}
+
+message Member {
+ string user_id = 1;
+ string organization_id = 2;
+ string role = 3;
+ bool active = 4;
+}
+
+message ListAssignableMembersRequest {
+ string organization_id = 1;
+ repeated string roles = 2;
+}
+
+message ListAssignableMembersResponse {
+ repeated Member members = 1;
+}organization_id stays a required field on both new requests, exactly as GetLoginIdentity established — a tenant-less directory read is not expressible in this contract. Adding RPCs and messages to a published service is a backward-compatible change: user-service can ship the new handlers before complaint-service ever calls them.
package complaint
import (
"context"
"fmt"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
identityv1 "github.com/hoa-platform/backend/gen/identity/v1"
)
// UserDirectoryClient implements UserDirectory against user-service's
// IdentityDirectory. It is the only place in complaint-service that knows
// user-service exists, and it speaks the generated contract rather than an
// ad-hoc JSON shape that could drift without a compile error.
type UserDirectoryClient struct {
directory identityv1.IdentityDirectoryClient
}
func NewUserDirectoryClient(directory identityv1.IdentityDirectoryClient) *UserDirectoryClient {
return &UserDirectoryClient{directory: directory}
}
var _ UserDirectory = (*UserDirectoryClient)(nil)
func (c *UserDirectoryClient) Lookup(ctx context.Context, orgID, userID uuid.UUID) (UserRecord, error) {
member, err := c.directory.GetMember(ctx, &identityv1.GetMemberRequest{
OrganizationId: orgID.String(),
UserId: userID.String(),
})
if status.Code(err) == codes.NotFound {
// A user outside this org is indistinguishable from one that does
// not exist, which is the same answer the tenant boundary requires.
return UserRecord{}, ErrAssigneeNotInOrg
}
if err != nil {
return UserRecord{}, fmt.Errorf("complaint: identity lookup failed: %w", err)
}
return toUserRecord(member)
}
func (c *UserDirectoryClient) EligibleAssignees(ctx context.Context, orgID uuid.UUID) ([]UserRecord, error) {
resp, err := c.directory.ListAssignableMembers(ctx, &identityv1.ListAssignableMembersRequest{
OrganizationId: orgID.String(),
Roles: []string{string(RoleStaff), string(RoleOrgAdmin)},
})
if err != nil {
// An unreachable directory must never look like "nobody is eligible",
// which would route every new complaint to the same fallback.
return nil, fmt.Errorf("complaint: identity directory unavailable: %w", err)
}
records := make([]UserRecord, 0, len(resp.GetMembers()))
for _, member := range resp.GetMembers() {
record, err := toUserRecord(member)
if err != nil {
return nil, err
}
records = append(records, record)
}
return records, nil
}
func toUserRecord(member *identityv1.Member) (UserRecord, error) {
id, err := uuid.Parse(member.GetUserId())
if err != nil {
return UserRecord{}, fmt.Errorf("complaint: identity returned an unparseable user id: %w", err)
}
orgID, err := uuid.Parse(member.GetOrganizationId())
if err != nil {
return UserRecord{}, fmt.Errorf("complaint: identity returned an unparseable organization id: %w", err)
}
return UserRecord{ID: id, OrgID: orgID, Role: Role(member.GetRole()), Active: member.GetActive()}, nil
}The compile-time assertion on the third line is the point: UserDirectory has two methods, and a client that implements only one is a build failure here rather than a nil-pointer panic during an assignment. Note also that gRPC status codes replace HTTP status parsing — NotFound is a typed constant, not a magic number read from a response.
A subtle failure mode remains on our side of the boundary: counting historical assignments instead of active ones. If unassigned_at is ignored, operators who handled many closed tickets last month will appear overloaded today. Forgetting to filter complaints by open statuses does the same thing. OpenAssignmentCountsSQL needs both conditions, and the integration test in this phase's testing lesson is what keeps them there.
How do same-org and role checks fail safely, and how do you verify them locally?
The correct rejection here is explicit. Assigning to a resident should return a business error, not silently succeed and create a ticket no operator ever sees. Assigning across orgs should fail even if the target user UUID exists and even if the caller is ORG_ADMIN. The safe failure is a 422-style business response, because the request is syntactically valid but violates assignment policy.
Applied exercise
Pick the next assignee from a live queue snapshot
The society manager has four eligible operators: staff-a with 5 open complaints, staff-b with 2, staff-c with 2, and admin-d with 7. A new plumbing complaint arrives and the UI requests an automatic suggestion.
- Apply the least-loaded rule to determine the suggested assignee from the four operators.
- Define the tie-break behavior for staff-b and staff-c so two repeated requests return the same winner.
- Show how the answer changes after staff-b receives one more active complaint but staff-c closes one of theirs.
- Explain why closed complaints and historical assignment rows must not count toward current load.
Deliverable
A short note naming the initial winner, the tie-break rule, and the updated winner after the queue changes.
Completion checks
- The chosen assignee comes from the smallest active open-complaint count.
- The tie-break is deterministic rather than random.
- Historical rows with unassigned_at set are excluded from the load calculation.
Why is complaint_assignments modeled as history rows with unassigned_at instead of a single assignee_user_id column on complaints?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.