Browse Templates/Helpdesk Ticketing

Helpdesk Ticketing

A production-ready helpdesk and ticketing system database schema. Features customer and agent management, multi-channel ticket tracking with threaded messages, category-based organization, and SLA policy enforcement with priority-based response and resolution time targets. Ideal for customer support platforms, IT service desks, or internal issue tracking systems.

PostgreSQL6 tablesSaaS
helpdeskticketingsupportslacustomer-serviceagents
Created with ERD Studio

About this schema

The SLA is stored as a policy and measured with timestamps. sla_policies defines first_response_minutes and resolution_minutes per priority, and tickets records first_responded_at and resolved_at. A breach is therefore arithmetic against a row that is attached to the ticket — not a number buried in reporting code that nobody can audit.

sla_policy_id is pinned onto the ticket rather than looked up by priority at read time. Policies get renegotiated, and a ticket opened under last quarter's terms has to keep being judged by them. Copying the reference at creation is what makes a historical SLA report reproducible after the policy changes.

Agents and customers are separate tables because they are different populations, and ticket_messages reconciles that with sender_type plus sender_id. One thread then holds the customer's reply and the agent's answer in order, with is_internal_note marking the lines the customer must never see.

Key relationships

customers → tickets → ticket_messages
The request and its conversation. Every message belongs to a ticket, so a thread cannot exist without the case that created it.
tickets → agents (assigned_agent_id)
Nullable, because a ticket arrives in a queue before anyone owns it. is_available on the agent is what routing reads.
tickets → sla_policies
The policy is attached to the ticket at creation, so the targets it is measured against do not move when the policy is edited.
categories → categories (parent_id)
A self-referencing tree, so Billing > Refunds is possible without a second table or a fixed depth.
ticket_messages → sender_type + sender_id
A polymorphic sender so agents and customers share one thread. The database cannot enforce it, which is the price of the single timeline.

Design decisions

Response time is two timestamps, not a computed status

first_responded_at and resolved_at are written once, when those things happen. Deriving them from the message history would make every SLA figure depend on message metadata staying correct, and an internal note or an automated reply would quietly count as a first response. Two explicit columns mean the moment is recorded deliberately, and a null is an unambiguous not yet.

The SLA policy is referenced, not inlined

Copying first_response_minutes onto every ticket would freeze the targets perfectly and duplicate them millions of times. Pointing at the policy row keeps one definition and still freezes history, because the reference is captured at creation — changing a policy creates a new row rather than editing the one tickets already point at. That discipline is what the design depends on.

Internal notes live in the same thread

is_internal_note on ticket_messages keeps staff discussion in chronological order beside the customer conversation, which is how an agent picking up a handover understands it. The risk is obvious and one-sided: a read path that forgets the filter shows private notes to the customer, so the flag has to be applied at the query, never in the template.

Agents and customers are not one users table

Merging them with a role column is tempting and wrong here. The two populations have different fields, different lifecycles and very different numbers — customers arrive by the thousand and are often created from an inbound email alone. Separating them costs the polymorphic sender on messages, which is the one place the design pays for the split.

Tables in this template

agentsAgents
7 cols
customersCustomers
6 cols
categoriesCategories
5 cols
sla_policiesSLA Policies
6 cols
ticketsTickets
14 cols
ticket_messagesTicket Messages
8 cols

Frequently asked

How do I find tickets that breached their SLA?
Join the ticket to its sla_policy and compare created_at plus first_response_minutes against first_responded_at, then the same for resolution. A null first_responded_at past the target is an open breach, which is the row that actually needs attention.
Why is assigned_agent_id nullable?
Because unassigned is a real state — a ticket sits in a queue until someone takes it. Requiring an agent would mean a placeholder row that every workload report then has to exclude.
Can a customer see internal notes?
Only if the application forgets to filter is_internal_note. The schema stores the distinction; enforcing it belongs to the query that builds the customer view, which is why that filter should sit in one shared function rather than in each template.
What is the channel column for?
It records how the ticket arrived — email, chat, phone, a web form. Volume by channel is what staffing decisions are made from, and it cannot be reconstructed later from the message thread alone.
Why is there no ticket history or status log?
Deliberately omitted to keep the schema readable. status holds the current value only, so how long a ticket spent waiting on the customer is not answerable — a ticket_events table is the known addition when that question starts being asked.