Support System
A multi-tenant helpdesk and contact-centre platform, specified as a product first: ticket lifecycle, SLA clocks, and routing rules written down as decisions.
An omnichannel ticketing platform with a Go API and workers, a Next.js agent app, and a visual bot-flow builder. The product work is the centre of the case study: a six-state ticket lifecycle, SLA clocks that pause and resume in business time, and a layered routing policy that records why each ticket went to each agent.
- Role
- Product owner and sole builder - product definition, requirements, and implementation
- Team
- Solo project
- Last updated
- October 2026
- Links
- Documentation
Disciplines
- Product Management
- Business Analysis
- Software Engineering

System stack
What the system is built with. Personal contribution is itemised per discipline below.
- Go 1.26
- PostgreSQL 17
- Row-level security
- Redis 7
- Next.js 16
- React 19
- TypeScript
- Tailwind CSS v4
- TanStack Query
- React Flow
- WebSockets
- Docker Compose
- Vitest
- Playwright
At a glance
Every figure states its basis. None are projections.
- 6 states / 15 transitions
- in the ticket state machine
- 3
- SLA clocks per ticket
- 6
- channel types
- 47
- permission keys in one catalog
- 27 (25 non-canvas)
- bot-flow node types
- 35
- backend test functions
the transitions map in internal/modules/ticket/status.go; closed has no outgoing transition
first response, next response, and resolution, each in business minutes (internal/modules/sla/sla.go)
website chat, email, WhatsApp, SMS, voice/CTI, and a signed API channel, as listed on the Channels settings page
count of entries in internal/platform/authz/catalog.go; roles and API keys can only hold these keys
27 types registered in internal/modules/botflow/nodes.go; two, ui.comment and ui.frame, are canvas annotations by name
6 _test.go files in 5 packages (ticket status, routing pick and policy, SLA calendar, bot engine, router guards). Most modules have no unit tests; this is not a coverage figure
Overview
Problem
Support teams lose time and customers in the gaps between tools. Email, chat, WhatsApp, SMS, and phone each create a separate queue. Tickets are assigned by whoever notices them, so the same customer explains their problem to a different agent each time. SLA targets live in a spreadsheet and are checked after they have been missed. Supervisors learn that a queue is backing up from an angry customer rather than from a screen.
Why I built it
I wanted a project where the product thinking is the visible part. Ticketing looks simple until you have to decide whether a customer reply reopens a solved ticket, whether an on-hold ticket still counts against an agent's capacity, and what an SLA clock does while you wait for the customer. Each of those is a product decision with a cost to somebody, and a ticketing system is a domain where I could make all of them explicitly and then check the working software against them.
Who it’s for
Support agents who work tickets all day, supervisors who own queues and SLAs, and workspace administrators who configure roles, routing, and branding. Customers reach the system through a chat widget, email, WhatsApp, SMS, or voice events, and answer a CSAT survey, but they do not sign in.
Context
The market has mature helpdesks, so this is not an attempt to out-feature them. It is a worked answer to a narrower question: what are the product rules that make a ticketing system trustworthy to the agents who work in it and the supervisors who answer for it? The rules (what a status means, when a clock stops, who gets a ticket and why) are written down and implemented rather than left implicit.
Objectives
- Define a ticket lifecycle with no ambiguous states and no silent transitions.
- Make SLA measurement fair: the clock stops when the ball is in the customer's court.
- Route work by explicit, inspectable rules, and record the reason for every assignment.
- Keep each customer's data isolated from every other customer's, enforced in the database.
- State plainly what has been built, what has been verified, and what has not.
Scope & stakeholders
In scope
- Ticket lifecycle with a six-state machine, priorities, types, merge, links, and bulk actions
- Queues, skills, languages, capacity, shifts, presence, overflow, sticky routing, and rebalancing
- SLA policies with business-hour calendars, holidays, pause and resume, warnings, and breach events
- Six channels: website chat widget, email, WhatsApp Cloud API, SMS, voice and CTI events, and a signed API channel
- Visual bot-flow builder with versioned publishing and a test simulator
- Roles and permissions per workspace, API keys, OAuth2 client credentials, signed webhooks, and an audit log
- Wallboard, analytics, CSAT, and a knowledge base with a public help centre
Deliberately out of scope
- AI features of any kind - no LLM replies, no suggested answers, no semantic search
- A hosted deployment or any production traffic
- Billing, plans, or usage metering
- A customer portal where customers sign in and track tickets
- Object storage for attachments - files go to local disk
- Single sign-on
- Continuous integration
The product documents treat these exclusions as decisions to revisit, not oversights. A workspace of this size does not need SSO to prove the ticketing model, and a hosted deployment would add operations work that says nothing about the product rules.
Stakeholders
Marked by whether the system as built actually serves them.
Support agent
ServedA queue of work that is routed to them fairly, with the customer's history in view and no surprise reassignments.
Supervisor
ServedA live view of the queue and agents, SLA risk before a breach, and control over routing rules.
Workspace administrator
ServedRoles, branding, channels, business hours, and an audit trail they can review.
Developer / integrator
Partially servedAn API with scoped keys, idempotent writes, and signed webhooks to connect a CRM or order system.
Customer
Partially servedA reply on the channel they used, a follow-up that is not lost, and a chance to rate the service.
Platform operator
Partially servedTenant provisioning and a way to run the system for many workspaces.
Finance / billing owner
Not servedPlans, invoices, and usage by workspace.
Product Management
The product is a set of rules about what a ticket is, when a clock runs, and who gets the work. I wrote the rules first and checked the software against them.
A ticket has six states, and each one means something
New, open, pending, on hold, solved, closed. Pending means we are waiting on the customer. On hold means we are waiting on someone else. Solved is the agent's claim that the problem is fixed, and it can still be reversed. Closed is final: a closed ticket cannot be changed, reassigned, merged, or replied to publicly, and a customer who writes again gets a new, linked follow-up ticket instead of resurrecting the old one. The state machine allows 15 transitions and rejects everything else with a conflict error, so no integration or script can put a ticket into a state the product does not define.
The SLA clock stops when the ball is in the customer's court
Each ticket has up to three clocks: first response, next response, and resolution, measured in business minutes against a calendar with a timezone and holidays. Moving a ticket to pending or on hold pauses them. Moving it out of those states resumes them and pushes every due date forward by the business time that was spent paused. This is the fairness rule: an agent is not penalised for a customer who took two days to answer, and a supervisor's breach report is not inflated by it. The cost is complexity, since resume has to recompute dates against the calendar rather than just adding elapsed time.
Routing is a policy, and every assignment carries a reason
A ticket is routed in a fixed order. A manual queue just holds it. Otherwise, if sticky routing is on and the customer was served recently, it goes back to the agent who helped them, provided that agent is active, online, in the queue's team, in shift, skilled, and speaks the language. If not, the system picks among qualified agents with spare capacity, preferring those in shift, ordered by utilisation (least loaded) or by who was assigned longest ago (round robin). If nobody qualifies, the ticket waits in the queue with the reason recorded. Every assignment stores its method and reason, so the question a supervisor actually asks, why did this ticket go to that agent, has an answer on the ticket.
Rules are layered so a queue can differ from the workspace
Routing behaviour comes from built-in defaults, overridden by the workspace, overridden again by the queue. Overrides are partial, unknown keys are rejected, and ranges are validated when saved. A returning-customer queue can be sticky while a first-line queue is not, without anyone copying a full configuration. The settings page describes each rule in plain language, because the person changing it is a supervisor, not an engineer.
What I chose not to build
No AI replies, no billing, no customer portal, no hosted deployment. Each was a deliberate cut. The ticketing rules are the product, and every one of those features would have added surface area without testing a single rule. They are listed as exclusions in scope and as items on the roadmap, not hidden.
My contribution
Defined the ticket lifecycle and its transition rules.
Six states, 15 permitted transitions, immutable closed tickets, and linked follow-ups instead of reopening.
- Ticket Lifecycle, SLA, and Routing Specification
- Ticket state machine
Specified SLA pause and resume semantics in business time.
Pending and on hold pause the clocks; resume shifts due dates by the business time paused.
- Ticket Lifecycle, SLA, and Routing Specification
- SLA planner and scanner
- Transition, reply, and reopen logic
Designed the routing decision order and the layered policy.
Sticky, then qualified-and-available selection, then queue with a recorded reason; defaults, workspace, and queue layers.
- Ticket Lifecycle, SLA, and Routing Specification
- Agent selection
- Layered routing policy
Wrote the case study, roadmap, and KPI framework.
Including an inventory of which proposed metrics are not instrumented.
Current limits
- No customer discovery. The problem statement, personas, and priorities are reasoned from domain knowledge and from the shape of the data model, not from interviews with a support team.
- No business model, pricing, or go-to-market plan.
- No outcome data. There are no users, so the KPI framework is a proposal, not a record.
Business Analysis
Requirements, user stories, and personas written after the build, each requirement marked implemented, partial, or not implemented.
Requirements with a status
The PRD lists requirements with identifiers and attaches a status taken from the code: implemented, partially implemented, or not implemented. A requirement the system does not meet is recorded as such. The value is the honest gap list, which is the part a reviewer would otherwise have to discover.
Stories with acceptance criteria
User stories cover the ticket lifecycle, SLA, routing, bot flows, channels, and access control, each with given/when/then acceptance criteria. Several are written directly from edge cases found in the code, such as what happens to a customer's reply on a pending ticket, an on-hold ticket, and a closed ticket.
Personas and positioning
Four representative personas (agent, supervisor, administrator, customer) and a category-level competitive analysis. Both are labelled as retrospective. The competitive analysis names categories and well-known capabilities, and makes no claim about a specific vendor's current features or pricing, because that was not researched.
My contribution
Wrote a PRD with status-tagged requirements and acceptance criteria.
Requirements are tagged against the implemented system rather than an aspiration.
Produced personas and a category-level competitive analysis.
Current limits
- Requirements were derived from a built system. Written first, they would have been better analysis and a worse record of what exists.
- Personas were not validated with real people.
- The competitive analysis is positioning, not a feature comparison.
Software Engineering
A Go modular monolith and a Next.js agent app. Engineering is context here: it exists to show that the product rules run.
Architecture
A Go API and worker over PostgreSQL 17 and Redis 7, organised into modules (tickets, routing, SLA, channels, bot flows, integrations, analytics, and others) with explicit dependency wiring. State changes publish events through a transactional outbox so delivery to routing, automations, webhooks, and the browser happens at least once, in the same transaction as the change. Background schedulers run under a Redis leader lock, so more than one instance can run safely.
Tenant isolation in two layers
Every query filters by tenant, and PostgreSQL row-level security is forced on every tenant table as a second line of defence. The application connects as a role that cannot bypass it. Jobs that legitimately cross tenants use an explicit system context. The cost is that cross-tenant work has to be deliberate.
Agent app
Next.js 16 and React 19 with TanStack Query. A WebSocket per tab patches the query cache as events arrive, including collision detection when two agents open the same ticket. All visual values live in one theme file, and a check fails the build on hard-coded colours.
Security basics
httpOnly cookies with CSRF double-submit, rotating refresh tokens with reuse detection, TOTP two-factor, argon2id password hashing, secrets encrypted at rest with versioned keys, and an append-only audit log. These were exercised through a smoke test rather than isolated tests.
My contribution
Implemented the ticket, SLA, and routing rules in the backend.
The state machine and the agent-selection function are pure and unit-tested.
- Ticket state machine
- Agent selection
- Layered routing policy
- SLA planner and scanner
Built a permission catalog shared by roles and API keys.
- Permission catalog
Current limits
- Most backend modules have no unit tests, including identity, integrations, channels, and the outbox. The security properties are exercised only by a smoke test against a running stack.
- The frontend has one unit test file and four browser tests.
- No continuous integration, so none of the test suites run automatically.
- File storage is local disk only.
- The OpenAPI document covers only part of the API.
Decisions & trade-offs
Every decision cost something. These are the ones worth defending, with what each gave up.
Closed tickets are immutable; a customer reply creates a linked follow-up.
A closed ticket has already been counted in resolution metrics. Reopening it silently rewrites history. A follow-up keeps the record intact and still carries the context through a link.
Alternatives considered
- Reopen on any customer reply - simplest, but it corrupts resolution metrics
- Allow reopen for a window after closing - familiar, but adds a second rule to explain
Trade-off: Agents see a new ticket number for what the customer thinks is one conversation, so the link has to be easy to follow.
SLA clocks pause in pending and on hold, and resume in business time.
Waiting on a customer or a third party is not the agent's delay. Measuring it as one punishes the wrong people and hides real slowness behind noise.
Alternatives considered
- Wall-clock SLAs - simple, but nights and weekends cause breaches nobody caused
- Pause only for pending - leaves on-hold tickets accruing time while waiting on someone else
Trade-off: Resume has to recompute due dates against a calendar, and an agent can misuse pending to stop a clock. That needs a metric to watch, which is on the KPI list.
Sticky routing sends returning customers to their previous agent when that agent is eligible.
Customers resent repeating themselves, and continuity shortens resolution. The eligibility checks (online, in shift, skilled, language, optional capacity) keep continuity from stranding a ticket with someone who cannot work it.
Alternatives considered
- Always route by queue strategy - fair, but discards context
- Always send to the previous agent - best continuity, worst when they are away
Trade-off: Continuity can load one agent unevenly. The policy lets a workspace choose whether capacity is respected, and rebalancing protects sticky assignments by default.
Routing behaviour is a layered policy, not a set of fixed rules.
Queues differ. A billing queue wants continuity, and a first-line queue wants speed. Defaults, workspace, and queue layers let each differ without copying configuration.
Trade-off: Behaviour depends on three places, so the settings page has to show which values are overridden.
Record the method and reason for every assignment.
Agents and supervisors will ask why a ticket landed where it did. A reason stored on the ticket answers that without anyone reading code or logs.
Trade-off: More fields to store and display on every ticket.
Two-layer tenant isolation: application filters plus forced row-level security.
A missed filter in a multi-tenant system is a data breach. A second, database-enforced layer turns that mistake into an empty result.
Alternatives considered
- Application filters only - simpler, one bug away from a leak
- A database per tenant - strongest isolation, much heavier to operate
Trade-off: Cross-tenant jobs must opt in explicitly, and every tenant table must carry the policy.
A transactional outbox instead of a message broker.
Events are written in the same transaction as the change they describe, so there is no state where a ticket changed and nobody was told.
Alternatives considered
- Kafka or a queue service - more throughput, more infrastructure for a system of this size
- Publish after commit - simple, but loses events on a crash
Trade-off: Delivery is at least once, so every handler has to be idempotent.
Leave AI out.
The product question was whether the ticketing rules hold up. A language model would have added a feature to demo and nothing to test.
Trade-off: The system lacks the capability a buyer will ask about first. It is on the roadmap as a later item, with its dependencies named.
Challenges
- Resuming an SLA clock correctly: the due dates have to move by business time, not elapsed time, and the calendar includes holidays and a timezone.
- Deciding what a customer reply does in each state. The code reopens solved and pending tickets and leaves on-hold tickets alone, which is a rule worth stating rather than discovering.
- Routing without double-assigning under concurrency, solved with a per-queue advisory lock so different queues still route in parallel.
- Keeping rebalancing from undoing continuity, which is why untouched tickets only, a minimum wait, and sticky protection are all conditions.
- Writing a PRD after the build and keeping it honest about the gaps.
Media
Screenshots, recordings, and diagrams from the running system.
Agent workspace



Routing and SLA

Bot flows

Operations



Administration



Evidence
The documents, code, and running system behind the claims above.
Flagship narrative: problem, users, principles, the ticketing model, key decisions, trade-offs, and what is not built.
The ticket-management rules as a product spec: state machine, reply and reopen behaviour, SLA pause and resume, and the routing decision order.
Requirements with IDs, user stories, and given/when/then acceptance criteria. Each requirement states its implementation status.
Representative personas for the agent, supervisor, administrator, and customer, with the caveat that none were validated with real people.
Positioning against the helpdesk and contact-centre categories, with no vendor feature claims that were not checked.
Proposed metrics, which ones the analytics module already computes, and which are not instrumented.
Ticket state machine
The transitions map, the pause rule, and the terminal check, as a small pure module.
support-system-backend / internal/modules/ticket/status.go
Transition, reply, and reopen logic
SLA pause and resume, solved and closed stamps, reopen counting, follow-up tickets for closed tickets, and merge.
support-system-backend / internal/modules/ticket/service.go
SLA planner and scanner
First-match policy selection, business-time due dates, and the warning and breach scanner.
support-system-backend / internal/modules/sla/sla.go
Agent selection
A pure function: qualify, check capacity, prefer in-shift agents, then order by strategy.
support-system-backend / internal/modules/routing/pick.go
Layered routing policy
Defaults, then workspace override, then queue override, with unknown keys rejected and ranges validated.
support-system-backend / internal/modules/routing/policy.go
Permission catalog
The single source of truth for roles and API-key scopes.
support-system-backend / internal/platform/authz/catalog.go
Documentation
Written during the build, not for the portfolio. Published as-is.
Product
- Product Case StudyFlagship narrative: problem, users, principles, the ticketing model, key decisions, trade-offs, and what is not built.
- Ticket Lifecycle, SLA, and Routing SpecificationState machine, reply and reopen behaviour, SLA pause and resume, routing decision order, and assignment reasons.
- Product RoadmapNow, Next, and Later.
- Metrics and KPI FrameworkProposed metrics and an explicit instrumentation-gap inventory.
Limitations
What this does not do, stated plainly. A reviewer would find these anyway.
- No real users, no customer discovery, and no measured outcomes.
- Runs locally against seeded demo data. There is no hosted deployment.
- No continuous integration, and no measured test coverage.
- Backend unit tests cover a minority of modules; security properties are checked by a smoke test.
- Frontend tests are limited to one unit test file and four browser tests.
- No AI features of any kind.
- File attachments are stored on local disk.
- The knowledge base uses full-text search, not semantic search.
- OpenAPI documentation covers part of the API.
- No billing, no customer portal, and no single sign-on.
What I learned
- The hard part of a ticketing system is the rules between the states, not the states. A reply to a pending ticket and a reply to an on-hold ticket are different product decisions.
- A policy a supervisor cannot read will not be changed. Describing each rule in plain language was as much design work as the rule.
- Recording a reason on every automated decision is cheap when you design for it and expensive to add later.
- A fair SLA is a product choice with a cost. Pausing clocks protects agents and gives them a lever to abuse, so the metric that watches the lever belongs in the same document.
- Writing requirements after the build is documentation, not analysis. It was still the fastest way to find where the code and the intent disagreed.
Roadmap
Now
Continuous integration and a tenant-isolation test suite
Run the existing tests on every change and add direct tests for row-level security and CSRF, which are currently covered only by a smoke test.
Complete the OpenAPI document
Cover every public route so integrators do not read source code.
Instrument the missing KPIs
Reopen rate, time spent paused, and SLA performance by queue, so the pending-pause lever can be watched.
Next
Hosted staging environment
Deploy somewhere reachable, with object storage for attachments.
Customer portal
Let customers see and reply to their tickets without email.
Single sign-on
A requirement for any larger workspace.
Later
AI-assisted triage and suggested replies
Depends on real ticket data and on a human-review rule, in the same way the lifecycle depends on explicit states. Not started.
Billing and plans
Only meaningful once there is a deployment and a customer.
Skills demonstrated
- Product Management
- Product scopingWorkflow and state designPolicy designSLA designPrioritisationKPI definitionRoadmap planningTrade-off analysis
- Business Analysis
- Requirements elicitationPRD and user storiesAcceptance criteriaProcess mappingPersona developmentCompetitive positioningGap analysis
- Software Engineering
- GoREST API designPostgreSQLRow-level securityTransactional outboxNext.jsReactTypeScriptWebSocketsTesting
- Tools
- GitDockerPlaywrightVitestOpenAPI
Next
Questions about any of this — including the parts that don’t work yet — are the ones worth having.