Stage 7 · Master
Phase 10 — Complaint Service
Complaint Workflow
Model plumbing, electrical, security, noise, common-area, and parking complaints with one state machine whose categories influence routing and SLA instead of branching the workflow itself.
Why should plumbing and parking complaints share one workflow?
By module 10, Meridian already has services that own flats, residents, maintenance dues, and payment history. complaint-service consumes their tenant-scoped identifiers and selected facts through explicit contracts; it does not own or join their databases. Its own temptation is to branch the complaint model immediately: plumbing does one thing, noise another, security a third. In production, that decays into six miniature ticket systems. The better model is one complaint workflow where category expresses the problem and status expresses where the work is in the queue.
The category list is still concrete and domain-specific because HOA operations are concrete: plumbing leaks, electrical faults, security incidents, noise disturbances, common-area maintenance, and parking disputes. Categories affect who gets paged, which SLA policy applies, and which dashboard slice operations managers watch. They should not fork the state machine, because a resident still opens a complaint, staff still acknowledge it, work still moves in progress, and resolution still needs confirmation regardless of whether the issue was a broken lift light or a basement water line.
- plumbing — leaks, drainage blocks, low pressure, overhead tank issues
- electrical — corridor lighting, meter sparks, transformer trips, backup generator faults
- security — unauthorized entry, gate failure, CCTV outage, incident reports
- noise — repeated late-night disturbance, construction outside policy windows
- common_area — lifts, clubhouse, lobby, stairwell, landscape, garbage room
- parking — unauthorized use, slot obstruction, towing, visitor parking disputes
Once status means the same thing for every complaint, you get one transition guard, one RBAC policy surface, one metrics vocabulary, and one exhaustive test table. Category can still drive SLA, notification routing, and reporting without infecting core workflow code.
Which status edges are legal, and why are they explicit?
A complaint's lifecycle is small enough to state outright: open, acknowledged, in_progress, resolved, closed, reopened. The transition-table-as-code technique is not new — Organization Service used it for org status, and Payment Service hardened it with monotonic ranks so out-of-order webhooks could not walk a payment backwards. Complaints need neither ranks nor rank guards, because a human always drives the move and reopened legitimately returns to an earlier stage. What complaints add instead is that the *actor* decides whether an edge is legal, which is why the next lesson pairs every edge with a role.
The failure mode here is hidden transition creep. If one handler silently allows open -> resolved because a staff member was in a hurry, while another insists on open -> acknowledged -> in_progress -> resolved, the audit trail becomes meaningless. The adjacency map is the source of truth; handlers call it, tests lock it down, and every future state addition must change one obvious table instead of five dispersed branches.
Where does the first complaint schema live, and what does it own today?
The first migration creates the mutable complaint row: tenant scope, the flat it belongs to, category, workflow status, who raised it, and timestamps. That row answers operational questions like 'what is still open' or 'which category is backing up in this organization.' It does not try to be the audit log yet; the immutable timeline arrives in lesson 4. Notice the tenant shape stays consistent with the rest of complaint-service: org_id is mandatory, repositories always query by org_id from request context, and no code trusts a complaint UUID alone as access control.
raised_by, org_id and flat_id are all stored as plain UUIDs without foreign keys, because complaint data lives in complaint-service's database while organizations, flats and user accounts are owned by three other services entirely. Cross-service identity is still enforced in application code and JWT context, but the database should not pretend it can do a foreign-key join across service ownership boundaries. That is a subtle security point: tenant isolation starts with org_id scoping, but actor identity still comes from authenticated claims, not from a client-submitted body field.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE complaints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
flat_id UUID NOT NULL,
category TEXT NOT NULL CHECK (category IN ('plumbing', 'electrical', 'security', 'noise', 'common_area', 'parking')),
status TEXT NOT NULL CHECK (status IN ('open', 'acknowledged', 'in_progress', 'resolved', 'closed', 'reopened')),
raised_by UUID NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON COLUMN complaints.raised_by IS 'User ID issued by user-service; validated in application code, not by a cross-service foreign key.';
CREATE INDEX complaints_org_status_created_idx
ON complaints (org_id, status, created_at DESC);
CREATE INDEX complaints_org_category_created_idx
ON complaints (org_id, category, created_at DESC);
CREATE INDEX complaints_org_flat_created_idx
ON complaints (org_id, flat_id, created_at DESC);Start with one mutable row per complaint. Timeline, SLA, and assignment all layer on later without needing a schema rewrite.
DROP TABLE IF EXISTS complaints;package complaint
type Category string
const (
CategoryPlumbing Category = "plumbing"
CategoryElectrical Category = "electrical"
CategorySecurity Category = "security"
CategoryNoise Category = "noise"
CategoryCommonArea Category = "common_area"
CategoryParking Category = "parking"
)
type Status string
const (
StatusOpen Status = "open"
StatusAcknowledged Status = "acknowledged"
StatusInProgress Status = "in_progress"
StatusResolved Status = "resolved"
StatusClosed Status = "closed"
StatusReopened Status = "reopened"
)
var knownCategories = map[Category]struct{}{
CategoryPlumbing: {},
CategoryElectrical: {},
CategorySecurity: {},
CategoryNoise: {},
CategoryCommonArea: {},
CategoryParking: {},
}
var knownStatuses = map[Status]struct{}{
StatusOpen: {},
StatusAcknowledged: {},
StatusInProgress: {},
StatusResolved: {},
StatusClosed: {},
StatusReopened: {},
}
var allowedTransitions = map[Status]map[Status]struct{}{
StatusOpen: {
StatusAcknowledged: {},
},
StatusAcknowledged: {
StatusInProgress: {},
StatusResolved: {},
},
StatusInProgress: {
StatusResolved: {},
},
StatusResolved: {
StatusClosed: {},
StatusReopened: {},
},
StatusClosed: {
StatusReopened: {},
},
StatusReopened: {
StatusAcknowledged: {},
},
}
func IsKnownCategory(category Category) bool {
_, ok := knownCategories[category]
return ok
}
func IsKnownStatus(status Status) bool {
_, ok := knownStatuses[status]
return ok
}
func CanTransition(from, to Status) bool {
next, ok := allowedTransitions[from]
if !ok {
return false
}
_, ok = next[to]
return ok
}
func IsTerminal(status Status) bool {
return status == StatusClosed
}An adjacency map is easy to read, easy to diff, and easy to exhaustively test. That matters more than cleverness in workflow code.
Why must resolved and closed stay separate in an HOA complaint system?
In many admin tools, staff marks a ticket done and the workflow ends. That is a poor fit for an HOA because the resident lives with the outcome. A plumber may say the leak is fixed, but the resident may still see water under the sink that evening. resolved means operations believes the work is done. closed means the resident who raised the issue, or an ORG_ADMIN after a grace period with no resident response, accepts that the complaint can leave the active queue. Splitting those states prevents staff from self-certifying closure and preserves trust when building management and residents disagree about reality.
If resolved and closed collapse into one status, the audit trail loses the exact accountability question residents care about most: who said the issue was fixed, and who actually accepted that it was finished? That is a product bug, not just a naming bug.
How do you verify the workflow guard locally before the HTTP layer exists?
Start by validating the smallest trustworthy surface: the migration applies, the complaint package builds, and the pure transition guard behaves exactly as the diagram says. This is also where you catch tenant-isolation regressions early: if any repository method later reads complaints by id without org_id, the state machine may be correct while the security posture is still broken. Workflow correctness and tenant scoping are separate tests.
Applied exercise
Introduce an escalated state without breaking the graph
Your HOA wants operations to distinguish between ordinary in-progress work and items that blew through SLA badly enough to require escalation, but only after the ticket has actually been worked on.
- Add a new escalated status to the state table in a way that keeps the graph explicit rather than sprinkling one-off if statements into handlers.
- Permit in_progress -> escalated and escalated -> resolved, but reject open -> escalated and acknowledged -> escalated.
- Update the migration check constraint for complaints.status so the database and Go enum cannot drift apart.
- Extend the transition unit tests so every new from/to pair is covered, including the invalid ones.
Deliverable
A revised state.go, migration diff, and passing transition matrix tests showing escalated only appears on the intended path.
Completion checks
- The adjacency map, not a handler special case, is the source of truth for escalated.
- Invalid paths into escalated fail deterministically in tests.
- resolved and closed remain separate even after escalation is added.
Why is one generic complaint status enum preferable to per-category workflows for plumbing, security, and parking?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.