Reference Source: docs/db-schema.md

Database Schema

Table-by-table schema notes, keys, indexes, relationships, and operational behavior.

Andrea Helpdesk uses MySQL (InnoDB, utf8mb4_unicode_ci throughout). All tables use CREATE TABLE IF NOT EXISTS so the schema can be re-applied safely. The authoritative source is database/schema.sql; run make db-migrate to apply it.


Table of Contents

  1. agents
  2. customers
  3. ticket_number_sequences
  4. tickets
  5. ticket_participants
  6. replies
  7. attachments
  8. tags
  9. ticket_tag_map
  10. ticket_relations
  11. imap_accounts
  12. settings
  13. agent_notifications
  14. knowledge_base_categories
  15. knowledge_base_articles
  16. refresh_tokens
  17. audit_log
  18. Default Settings Reference
  19. Entity Relationship Summary
  20. Indexes and Performance Notes

1. agents

Stores staff members who log in to the helpdesk to manage tickets.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
nameVARCHAR(120)NODisplay name
emailVARCHAR(255)NOLogin email (unique)
password_hashVARCHAR(255)NObcrypt hash of password
roleENUM(‘admin’,‘agent’)NO’agent’admin bypasses all permission checks; agent is subject to per-row permission flags
can_close_ticketsTINYINT(1)NO1Permission to close/resolve tickets
can_delete_ticketsTINYINT(1)NO0Permission to soft-delete tickets
can_edit_customersTINYINT(1)NO0Permission to update customer records
can_view_reportsTINYINT(1)NO0Permission to access the Reports section
can_manage_kbTINYINT(1)NO0Permission to create/edit/delete knowledge base articles
can_manage_tagsTINYINT(1)NO0Permission to create/delete tags
signatureTEXTYESNULLPer-agent HTML email signature (appended to outbound replies, overrides global signature)
page_sizeTINYINT UNSIGNEDNO20Preferred rows per page for ticket lists and dashboard blocks (10 / 20 / 50)
themeVARCHAR(20)NO’light’UI theme preference: light or dark
browser_notifications_enabledTINYINT(1)NO0Whether this agent wants browser notifications while the app is open
is_activeTINYINT(1)NO1Soft-disable without deleting; inactive agents cannot log in
last_login_atDATETIMEYESNULLUpdated on successful login
last_update_check_atDATETIMEYESNULLLast successful silent background update check for this admin
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMP ON UPDATE

Unique keys: uq_agents_email on email

Indexes: idx_agents_active on is_active (used when listing assignable agents)

Notes:

  • Passwords are hashed with password_hash() (bcrypt, PHP default cost). The column is wide enough for argon2 if upgraded.
  • admin role agents are not subject to any of the can_* permission checks anywhere in the middleware or service layer.
  • signature is sanitised server-side before storage and rendered in the compose UI.
  • browser_notifications_enabled controls whether the frontend should show browser / OS notifications for this agent while the app is open.

2. customers

People who submit support requests. Customers do not have accounts in the traditional sense — they access their tickets via the customer portal using either a password or a magic-link token.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
nameVARCHAR(120)NODisplay name
emailVARCHAR(255)NOPrimary email (unique, case-insensitive lookup)
phoneVARCHAR(40)YESNULLOptional phone number
companyVARCHAR(120)YESNULLOptional company/organisation name
notesTEXTYESNULLInternal agent-only notes (not visible to customer)
portal_password_hashVARCHAR(255)YESNULLbcrypt hash of customer portal password. NULL means password login is not set up for this customer
portal_tokenVARCHAR(64)YESNULLSHA-256 hex hash of the one-time magic-link token. Raw token is sent in email; only the hash is stored
portal_token_expiresDATETIMEYESNULLExpiry timestamp for the magic-link token (typically 1 hour after generation)
suppress_emailsTINYINT(1)NO0When 1, all outbound emails to this customer are suppressed globally (auto-responder and agent replies). Overrides per-ticket suppression — if either is set, no email is sent.
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMP ON UPDATE
deleted_atDATETIMEYESNULLSoft-delete timestamp; NULL means active

Unique keys: uq_customers_email on email

Indexes: idx_customers_deleted on deleted_at

Notes:

  • Soft-deleted customers (deleted_at IS NOT NULL) are excluded from all normal queries but their tickets and replies remain intact.
  • The magic-link flow: both AuthController (customer self-request) and CustomerService (agent-sent invite) generate bin2hex(random_bytes(32)), store hash('sha256', $token) in portal_token, and email the raw token. PortalAuthController receives the raw token, hashes it, and queries by hash. This prevents token exposure if the DB is dumped.
  • Customers can have both a password and a magic-link token simultaneously.

3. ticket_number_sequences

Provides a collision-free counter for generating ticket numbers in the format PREFIX-YYYY-MM-DD-NNNN.

ColumnTypeNullableDefaultDescription
date_keyCHAR(10)NODate string YYYY-MM-DD (primary key)
last_seqINT UNSIGNEDNO0Last sequence number used on this date

Primary key: date_key

Notes:

  • One row per calendar day, created automatically on the first ticket of that day.
  • TicketRepository::generateTicketNumber() uses an atomic INSERT ... ON DUPLICATE KEY UPDATE last_seq = LAST_INSERT_ID(last_seq + 1) pattern. LAST_INSERT_ID() is then read in the same connection to get the just-assigned sequence number, avoiding race conditions between concurrent requests.
  • The first ticket of each day starts at a random number between 128 and 512 (chosen with random_int(128, 512)); subsequent tickets that day increment from there. This means ticket numbers are not zero-padded and do not start from 1. Example: HD-2026-03-20-347, HD-2026-03-20-348.

4. tickets

Core entity. Each row is one support ticket.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
ticket_numberVARCHAR(40)NOHuman-readable unique identifier, e.g. HD-2026-03-17-0001
subjectVARCHAR(255)NOTicket subject line
statusENUM(‘new’,‘open’,‘waiting_for_reply’,‘replied’,‘pending’,‘resolved’,‘closed’)NO’new’Current status
priorityENUM(‘low’,‘normal’,‘high’,‘urgent’,‘overdue’)NO’normal’Priority level
channelENUM(‘email’,‘web’,‘phone’,‘portal’)NO’email’How the ticket was created
customer_idINT UNSIGNEDNOFK → customers.id. The primary customer for this ticket
created_by_agent_idINT UNSIGNEDYESNULLFK → agents.id. Set when a ticket is manually created by an agent; used for agent activity reporting
assigned_agent_idINT UNSIGNEDYESNULLFK → agents.id. NULL means unassigned
original_message_idVARCHAR(512)YESNULLMessage-ID header of the first inbound email (used for email threading)
last_message_idVARCHAR(512)YESNULLMessage-ID of the most recent outbound email (used as In-Reply-To on next reply)
reply_to_addressVARCHAR(255)YESNULLCustom reply-to address for this ticket (overrides global setting)
parent_ticket_idINT UNSIGNEDYESNULLFK → tickets.id. Set when this ticket is a child/sub-ticket of another
merged_into_idINT UNSIGNEDYESNULLFK → tickets.id. Set on the losing ticket when two tickets are merged; the winning ticket’s ID goes here
suppress_emailsTINYINT(1)NO0When 1, all outbound customer-facing emails for this ticket are suppressed (auto-responder and agent replies). Can be toggled from the Ticket Info sidebar; each toggle is recorded as a system event in the thread. Slack and agent notifications are unaffected.
first_response_atDATETIMEYESNULLTimestamp of first agent reply. Used for SLA response-time reporting
last_attention_atDATETIMENOCURRENT_TIMESTAMPMost recent customer or agent activity used by SLA escalation
sla_high_notified_atDATETIMEYESNULLTimestamp of the last high-priority SLA reminder for the current inactivity cycle
sla_overdue_notified_atDATETIMEYESNULLTimestamp of the last overdue SLA reminder for the current inactivity cycle
closed_atDATETIMEYESNULLTimestamp when status was last set to closed
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMP ON UPDATE
deleted_atDATETIMEYESNULLSoft-delete timestamp

Unique keys: uq_tickets_number on ticket_number

Indexes:

IndexColumnsPurpose
idx_tickets_customercustomer_idTicket list filtered by customer
idx_tickets_created_by_agentcreated_by_agent_idAgent activity reporting for manually-created tickets
idx_tickets_agentassigned_agent_idTicket list filtered by assignee
idx_tickets_statusstatusTicket list filtered by status
idx_tickets_createdcreated_atChronological ordering
idx_tickets_deleteddeleted_atExclude soft-deleted in all queries
idx_tickets_deleted_status(deleted_at, status)Main ticket list filter (status + not deleted)
idx_tickets_deleted_agent(deleted_at, assigned_agent_id)Agent-filtered ticket list
idx_tickets_attentionlast_attention_atSLA escalation scans and overdue dashboards
idx_tickets_original_msgoriginal_message_id(191)Email threading lookup by Message-ID

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_tickets_customercustomer_idcustomers(id)RESTRICT
fk_tickets_created_by_agentcreated_by_agent_idagents(id)SET NULL
fk_tickets_agentassigned_agent_idagents(id)SET NULL
fk_tickets_parentparent_ticket_idtickets(id)SET NULL
fk_tickets_mergedmerged_into_idtickets(id)SET NULL

Status lifecycle:

new              → replied           (agent sends first reply)
                 → any status        (agent manually changes)
open             → replied           (agent sends reply)
                 → pending           (agent sets — waiting for customer)
                 → resolved / closed (agent closes)
waiting_for_reply → replied          (agent sends reply)
replied          → waiting_for_reply (customer sends reply)
pending          → waiting_for_reply (customer sends reply)
resolved         → waiting_for_reply (customer replies — ticket automatically reopens)
closed           → waiting_for_reply (customer replies — ticket automatically reopens)

Automatic transitions (no agent action required):

  • Any customer reply sets status to waiting_for_reply, even on resolved or closed tickets.
  • Any non-private agent reply (without an explicit status_after) sets status to replied; tickets already resolved or closed are not affected.

Notes:

  • Soft-deleted tickets (deleted_at IS NOT NULL) are hidden from all normal queries, including the customer detail screen (tickets list and comments/replies tab). The API exposes a GET /api/tickets?include_deleted=1 option for admins.
  • Merged tickets retain all their replies and attachments; merged_into_id links the losing ticket to the canonical one.
  • channel = 'email' is set by the IMAP poller; 'web' and 'portal' are set when agents/customers create tickets via the UI.
  • SLA escalation uses last_attention_at rather than updated_at so automatic priority changes do not reset the inactivity timer.

5. ticket_participants

CC recipients on a ticket — additional email addresses that receive notifications alongside the primary customer.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
ticket_idINT UNSIGNEDNOFK → tickets.id
emailVARCHAR(255)NOParticipant email address
nameVARCHAR(120)YESNULLDisplay name (may be NULL if extracted from email header only)
roleENUM(‘to’,‘cc’,‘bcc’)NO’cc’Recipient role on outbound emails
customer_idINT UNSIGNEDYESNULLFK → customers.id. Populated if this email matches an existing customer record
created_atDATETIMENOCURRENT_TIMESTAMP

Unique keys: uq_ticket_participant on (ticket_id, email) — one row per email address per ticket

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_participants_ticketticket_idtickets(id)CASCADE
fk_participants_customercustomer_idcustomers(id)SET NULL

Notes:

  • The customer portal auth logic also checks this table: a customer JWT grants access to a ticket if customer_id matches either tickets.customer_id or a ticket_participants.customer_id row.
  • Participants are added by agents from the ticket detail view or extracted from CC: / To: headers when an email is imported.

6. replies

Every message in a ticket thread — inbound emails, outbound agent replies, internal notes, and system events.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
ticket_idINT UNSIGNEDNOFK → tickets.id
author_typeENUM(‘agent’,‘customer’,‘system’)NOWho wrote this reply
agent_idINT UNSIGNEDYESNULLFK → agents.id. Set when author_type = 'agent'
customer_idINT UNSIGNEDYESNULLFK → customers.id. Set when author_type = 'customer'
body_htmlMEDIUMTEXTNOHTML body of the reply (sanitised before storage)
body_textMEDIUMTEXTYESNULLPlain-text version (stored for email threading and fallback)
is_privateTINYINT(1)NO01 = internal note visible only to agents, never emailed to customer
directionENUM(‘inbound’,‘outbound’)NOinbound = received from customer/email; outbound = sent by agent/system
raw_message_idVARCHAR(512)YESNULLMessage-ID header of this email (NULL for web-only replies)
in_reply_toVARCHAR(512)YESNULLIn-Reply-To header of this email
email_sent_atDATETIMEYESNULLTimestamp when the outbound email was dispatched (NULL until actually sent)
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMP ON UPDATE

Indexes:

IndexColumnsPurpose
idx_replies_ticketticket_idFetch all replies for a ticket
idx_replies_message_idraw_message_id(191)Email threading: match inbound In-Reply-To against stored raw_message_id

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_replies_ticketticket_idtickets(id)CASCADE
fk_replies_agentagent_idagents(id)SET NULL
fk_replies_customercustomer_idcustomers(id)SET NULL

Notes:

  • system replies are used for audit-trail events (e.g. “Status changed to Resolved.”, “Subject changed to …”, “Customer changed to …”) that appear in the thread timeline but are never emailed. The agent_id field is populated when a system event is triggered by an agent action, so the event displays the responsible agent’s name and a timestamp in the UI.
  • Private (is_private = 1) replies are excluded from customer portal API responses. They are visible to all agents.
  • first_response_at on the parent ticket is set to the created_at of the first reply where author_type = 'agent' and is_private = 0.

7. attachments

Files uploaded to a ticket or reply, stored on disk outside the web root.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
ticket_idINT UNSIGNEDNOFK → tickets.id
reply_idINT UNSIGNEDYESNULLFK → replies.id. NULL for ticket-level attachments not linked to a specific reply
filenameVARCHAR(255)NOOriginal filename as shown to the user (may contain Unicode)
stored_pathVARCHAR(512)NOPath relative to STORAGE_PATH/attachments/, e.g. 42/abc123_report.pdf
mime_typeVARCHAR(100)NOMIME type detected server-side via mime_content_type()
size_bytesINT UNSIGNEDNOFile size in bytes
download_tokenVARCHAR(255)YESNULLHMAC-SHA256 signed token for unauthenticated download URLs (time-limited)
uploaded_by_agent_idINT UNSIGNEDYESNULLFK → agents.id. Set when an agent uploaded the file
uploaded_by_customer_idINT UNSIGNEDYESNULLFK → customers.id. Set when a customer uploaded the file
created_atDATETIMENOCURRENT_TIMESTAMP

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_attachments_ticketticket_idtickets(id)CASCADE
fk_attachments_replyreply_idreplies(id)SET NULL
fk_attachments_agentuploaded_by_agent_idagents(id)SET NULL
fk_attachments_customeruploaded_by_customer_idcustomers(id)SET NULL

Notes:

  • Physical files live at {STORAGE_PATH}/attachments/{ticket_id}/{unique_filename}.
  • mime_type is always determined server-side using mime_content_type() after the file is saved to disk — the sender’s Content-Type is ignored to prevent MIME-type spoofing.
  • public_html/attachment.php serves files. It verifies either the download_token (HMAC) or a valid agent/customer JWT before calling readfile(). Path traversal is prevented by realpath() comparison against the attachments root.
  • Inline display is only permitted for a safe whitelist of MIME types (image/jpeg, image/png, image/gif, image/webp, application/pdf, text/plain, text/csv, video/mp4, video/webm, audio/mpeg, audio/wav, audio/ogg). Everything else gets Content-Disposition: attachment. text/html and image/svg+xml are intentionally excluded to prevent stored XSS.

8. tags

A flat list of labels that can be applied to tickets.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
nameVARCHAR(60)NOTag display name (unique, case-sensitive)

Unique keys: uq_tags_name on name


9. ticket_tag_map

Many-to-many join between tickets and tags.

ColumnTypeNullableDefaultDescription
ticket_idINT UNSIGNEDNOFK → tickets.id (composite PK)
tag_idINT UNSIGNEDNOFK → tags.id (composite PK)

Primary key: (ticket_id, tag_id)

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_ttm_ticketticket_idtickets(id)CASCADE
fk_ttm_tagtag_idtags(id)CASCADE

10. ticket_relations

Symmetric many-to-many link between two tickets (used for “related tickets” without implying hierarchy or merge).

ColumnTypeNullableDefaultDescription
ticket_a_idINT UNSIGNEDNOFK → tickets.id (composite PK, always the lower ID)
ticket_b_idINT UNSIGNEDNOFK → tickets.id (composite PK, always the higher ID)

Primary key: (ticket_a_id, ticket_b_id)

Check constraint: chk_rel_no_selfticket_a_id != ticket_b_id (prevents self-relation)

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_rel_aticket_a_idtickets(id)CASCADE
fk_rel_bticket_b_idtickets(id)CASCADE

Notes:

  • The service layer always inserts with the lower ticket ID as ticket_a_id to maintain the composite PK uniqueness (prevents duplicate rows with IDs swapped).
  • To fetch all related tickets for ticket X, query WHERE ticket_a_id = X OR ticket_b_id = X.

11. imap_accounts

Configuration for inbound email accounts polled by bin/imap-poll.php.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
nameVARCHAR(100)NOHuman-readable label for this account
hostVARCHAR(255)NOIMAP server hostname. Leading/trailing whitespace stripped on save.
portSMALLINT UNSIGNEDNO993IMAP port (993 = SSL, 143 = STARTTLS)
encryptionENUM(‘ssl’,‘tls’,‘none’)NO’ssl’Connection encryption method
usernameVARCHAR(255)NOIMAP login username — email address (user@domain.com) or Windows domain format (DOMAIN\user). Leading/trailing whitespace stripped on save.
from_addressVARCHAR(255)YESNULLOverride From address for outgoing emails; if NULL/empty, username is used
passwordTEXTYESNULLAES-256-CBC encrypted password (encrypted using JWT_SECRET from .env)
folderVARCHAR(100)NO’INBOX’IMAP folder to poll
delete_after_importTINYINT(1)NO0If 1, messages are deleted from the server after successful import
tag_idINT UNSIGNEDYESNULLAutomatically apply this tag to tickets created from this account
is_enabledTINYINT(1)NO1Disabled accounts are skipped by the poller
last_connected_atDATETIMEYESNULLLast time a connection was successfully established
last_poll_atDATETIMEYESNULLLast time the folder was polled (updated every run, even when no emails arrive)
last_poll_countINT UNSIGNEDNO0Number of messages imported in the last poll run
last_import_atDATETIMEYESNULLLast time at least one email was actually imported. Only updated when last_poll_count > 0, so this accurately reflects the most recent inbound email
created_atTIMESTAMPNOCURRENT_TIMESTAMP

Notes:

  • password is encrypted at rest using SettingsService::encrypt() (AES-256-CBC, key derived from JWT_SECRET). It is never returned in plain text via the API.
  • The legacy settings table also has imap_* keys for a single account. Multi-account support uses this table.
  • bin/imap-poll.php uses PHP’s native imap_* extension. It uses flock() on a lock file to prevent overlapping runs.
  • bin/imap-test.php is a CLI script used by the Settings UI “Test Connection” and “Browse Folders” buttons. It is invoked as a subprocess via proc_open so that DNS resolution and network access run in the CLI context (same as the cron poller), avoiding DNS issues that occur when making outbound connections from within the Apache web process.

12. settings

Runtime-configurable key/value store for application settings that don’t require a deploy to change.

ColumnTypeNullableDefaultDescription
key_nameVARCHAR(100)NOSetting key (primary key)
valueTEXTYESNULLSetting value (stored as string regardless of type)
typeENUM(‘string’,‘integer’,‘boolean’,‘json’)NO’string’Hint for how to cast the value when reading
group_nameVARCHAR(60)NO’general’Logical grouping for the settings UI (general, branding, email, imap, slack)
labelVARCHAR(120)NOHuman-readable label shown in the settings UI
updated_atDATETIMENOCURRENT_TIMESTAMP ON UPDATELast modification time

Primary key: key_name

Notes:

  • Values with type = 'boolean' are stored as '0' or '1'.
  • Values with type = 'integer' are stored as numeric strings.
  • Values with type = 'json' are stored as JSON strings.
  • Sensitive values (smtp_password, imap_password) are AES-256-CBC encrypted. SettingsService transparently encrypts on write and decrypts on read.
  • The INSERT in schema.sql uses ON DUPLICATE KEY UPDATE label = VALUES(label) so re-running the migration preserves customised values while updating label text.
  • See Default Settings Reference for the full list of keys.

13. agent_notifications

Per-agent in-app notification inbox entries shown in the navbar bell menu.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
agent_idINT UNSIGNEDNOFK → agents.id
typeVARCHAR(60)NONotification type, e.g. ticket_created, customer_reply, ticket_overdue, update_available
severityENUM(‘info’,‘success’,‘warning’,‘danger’)NOinfoUI severity styling
titleVARCHAR(180)NOPrimary notification headline
bodyTEXTYESNULLOptional secondary line shown in the bell menu / browser notification
linkVARCHAR(255)YESNULLInternal SPA route to open when clicked, e.g. /tickets/123
data_jsonTEXTYESNULLOptional structured payload for future client use
dedupe_keyVARCHAR(191)YESNULLOptional per-agent dedupe key so recurring checks/events do not create duplicate unread notifications
read_atDATETIMEYESNULLSet when the agent opens or marks the notification read
created_atDATETIMENOCURRENT_TIMESTAMP

Indexes: idx_agent_notifications_agent_created on (agent_id, created_at), idx_agent_notifications_agent_read on (agent_id, read_at)

Unique keys: uq_agent_notifications_dedupe on (agent_id, dedupe_key)

Notes:

  • Rows are created for each recipient agent independently; there is no shared “notification master” table.
  • Silent admin update checks use dedupe_key = update:<version> so each admin sees at most one unread notification per released version.
  • Ticket/reply/SLA notifications use deep links so clicking the bell entry navigates directly to the affected ticket or settings screen.

14. knowledge_base_categories

Top-level categories for grouping knowledge base articles.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
nameVARCHAR(120)NODisplay name
slugVARCHAR(120)NOURL-safe identifier (unique)
sort_orderINTNO0Display order (ascending); ties resolved by name
created_atDATETIMENOCURRENT_TIMESTAMP

Unique keys: uq_kbc_slug on slug


15. knowledge_base_articles

Individual knowledge base articles.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
category_idINT UNSIGNEDYESNULLFK → knowledge_base_categories.id. NULL = uncategorised
titleVARCHAR(255)NOArticle title
slugVARCHAR(255)NOURL-safe identifier, used in portal route #/kb/:slug (unique)
body_htmlMEDIUMTEXTNOArticle body as HTML
is_publishedTINYINT(1)NO01 = visible in customer portal; 0 = draft (agents only)
author_agent_idINT UNSIGNEDYESNULLFK → agents.id. The agent who created the article
view_countINT UNSIGNEDNO0Incremented each time the article is viewed (portal or agent UI)
source_ticket_idINT UNSIGNEDYESNULLFK → tickets.id. Set when an article was promoted from a ticket
created_atDATETIMENOCURRENT_TIMESTAMP
updated_atDATETIMENOCURRENT_TIMESTAMP ON UPDATE
deleted_atDATETIMEYESNULLSoft-delete timestamp

Unique keys: uq_kba_slug on slug

Indexes: ft_kba_search — FULLTEXT index on (title, body_html) — used by the agent dashboard search and portal search

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_kba_categorycategory_idknowledge_base_categories(id)SET NULL
fk_kba_authorauthor_agent_idagents(id)SET NULL
fk_kba_ticketsource_ticket_idtickets(id)SET NULL

Notes:

  • The FULLTEXT index enables MATCH(title, body_html) AGAINST(? IN BOOLEAN MODE) queries for article search.
  • Only published, non-deleted articles are returned by portal-facing API endpoints. Agents can see drafts.
  • slug is used directly in the SPA route: #/kb/{slug} (customer portal) and #/knowledge-base/{slug} (agent UI).

16. refresh_tokens

JWT refresh tokens used for session persistence and token rotation.

ColumnTypeNullableDefaultDescription
idINT UNSIGNEDNOAUTO_INCREMENTPrimary key
token_hashVARCHAR(64)NOSHA-256 hex hash of the raw refresh token. The raw token is sent to the client; only the hash is stored
agent_idINT UNSIGNEDYESNULLFK → agents.id. Set for agent sessions
customer_idINT UNSIGNEDYESNULLFK → customers.id. Set for customer portal sessions
expires_atDATETIMENOToken expiry (typically 30 days from creation)
revokedTINYINT(1)NO0Set to 1 when the token has been used (one-time rotation) or explicitly revoked (logout)
created_atDATETIMENOCURRENT_TIMESTAMP

Unique keys: uq_rt_hash on token_hash

Indexes: idx_rt_agent on agent_id

Foreign keys:

ConstraintColumnReferencesOn Delete
fk_rt_agentagent_idagents(id)CASCADE
fk_rt_customercustomer_idcustomers(id)CASCADE

Notes:

  • Access tokens (JWTs) are short-lived (15 minutes). Refresh tokens live in this table for 30 days.
  • Token rotation: each use of a refresh token revokes the old one (revoked = 1) and issues a new one. If a revoked token is presented, the session is considered compromised and all tokens for that user are revoked.
  • Exactly one of agent_id / customer_id will be non-NULL per row.
  • Expired and revoked tokens are not automatically cleaned up; a periodic cron or maintenance script is needed for housekeeping (not currently implemented).

17. audit_log

Immutable log of significant actions performed in the system.

ColumnTypeNullableDefaultDescription
idBIGINT UNSIGNEDNOAUTO_INCREMENTPrimary key (BIGINT for high volume)
actor_typeENUM(‘agent’,‘customer’,‘system’)NOWho performed the action
actor_idINT UNSIGNEDYESNULLID of the agent or customer (NULL for system actions)
actionVARCHAR(80)NOAction identifier, e.g. ticket.created, ticket.status_changed, agent.login
subject_typeVARCHAR(40)NOType of entity acted upon, e.g. ticket, customer, agent
subject_idINT UNSIGNEDNOID of the entity acted upon
payloadJSONYESNULLAdditional context as a JSON object (e.g. {"from": "open", "to": "closed"})
ip_addressVARCHAR(45)YESNULLIPv4 or IPv6 address of the client (45 chars covers full IPv6)
created_atDATETIMENOCURRENT_TIMESTAMP

Indexes:

IndexColumnsPurpose
idx_audit_subject(subject_type, subject_id)Fetch history for a specific entity
idx_audit_actor(actor_type, actor_id)Fetch all actions by a specific actor
idx_audit_createdcreated_atChronological queries and pruning

Notes:

  • Rows are never updated or deleted (append-only by design).
  • payload is flexible JSON — shape varies by action. Common patterns:
    • ticket.status_changed: {"from": "open", "to": "closed"}
    • ticket.assigned: {"agent_id": 3, "agent_name": "Jane"}
    • agent.login: {} (IP recorded separately)
  • BIGINT primary key is used because audit tables grow large in busy systems.

18. Default Settings Reference

The following settings are seeded by schema.sql. Values shown are the defaults; all can be changed at runtime via the admin settings routes (for example #/admin/settings/general).

General

KeyDefaultTypeDescription
ticket_prefixHDstringPrefix for ticket numbers (e.g. HD-2026-03-17-485)
app_urlstringAbsolute base URL of the helpdesk (e.g. https://support.example.com). Used in outbound email links and Slack alerts. Must be set after install. Trailing slashes are stripped automatically when building URLs.
timezonePacific/AucklandstringPHP timezone string for date display
date_formatd/m/Y H:istringPHP date() format string
imap_poll_modecronstringcron = external crontab; web = authenticated agent page views trigger the poller in the background
sla_enabled0booleanEnable inactivity-based SLA escalation
sla_high_after_days3integerDays with no attention before a ticket is raised to high
sla_overdue_after_days2integerAdditional days with no attention before a ticket is raised to overdue
sla_notify_scopeallstringall or specific for SLA reminder recipients
sla_notify_agent_ids[]jsonAgent IDs used when sla_notify_scope = specific

Branding

KeyDefaultTypeDescription
company_nameAndrea HelpdeskstringDisplayed in page title, emails, and portal
logo_url/Andrea-Helpdesk.pngstringURL to logo image shown in the navbar. Accepts root-relative paths (e.g. /my-logo.png) or absolute URLs. Falls back to /Andrea-Helpdesk.png if empty.
favicon_url/Andrea-Helpdesk-favicon.pngstringURL to favicon. Accepts root-relative paths or absolute URLs. Falls back to /Andrea-Helpdesk-favicon.png if empty.
primary_color#0d6efdstringBootstrap primary colour override (CSS hex)
accent_color#6610f2stringAccent colour (currently unused; reserved for theming)
custom_cssstringRaw CSS injected into <style> in the SPA shell
support_email_displaystringSupport email address shown to customers in the portal (display only; does not affect routing)

Email (SMTP)

KeyDefaultTypeDescription
smtp_hoststringOutbound SMTP server hostname
smtp_port587integerSMTP port
smtp_usernamestringSMTP auth username
smtp_passwordstringSMTP password (AES-256-CBC encrypted at rest)
smtp_from_addressstringFrom email address for outbound mail
smtp_from_nameAndrea HelpdeskstringFrom display name
smtp_encryptiontlsstringtls, ssl, or none
reply_to_addressstringGlobal Reply-To address (optional)
global_signature<p>--<br>Andrea Helpdesk</p>stringDefault HTML signature appended to all outbound emails unless overridden per-agent
notify_agent_on_new_ticket1booleanSend an email to all active agents when a new ticket is created
notify_agent_on_new_reply1booleanSend an email to the assigned agent (or all agents if unassigned) when a customer replies
auto_response_enabled1booleanWhether to send an automatic acknowledgement on new tickets
auto_response_subjectRe: {{subject}} [{{ticket_number}}]stringAuto-response subject template
auto_response_body(HTML template)stringAuto-response body HTML template. Placeholders: {{customer_name}}, {{ticket_number}}, {{subject}}, {{global_signature}}

IMAP (legacy single-account)

KeyDefaultTypeDescription
imap_hoststringIMAP server hostname
imap_port993integerIMAP port
imap_usernamestringIMAP login username
imap_passwordstringIMAP password (AES-256-CBC encrypted at rest)
imap_folderINBOXstringFolder to poll
imap_encryptionsslstringssl, tls, or none
imap_delete_after_import0booleanDelete messages from server after import

Slack

KeyDefaultTypeDescription
slack_enabled0booleanMaster switch for Slack notifications
slack_webhook_urlstringIncoming Webhook URL from Slack App configuration
slack_channel#helpdeskstringChannel name (overrides the webhook’s default channel)
slack_on_new_ticket1booleanNotify when a new ticket is created
slack_on_assign1booleanNotify when a ticket is assigned to an agent
slack_on_new_reply1booleanNotify when a customer replies to a ticket
slack_unfurl_links1booleanWhen enabled, Slack expands ticket links into rich preview cards
slack_usernamestringDisplay name shown on Slack messages (blank = webhook default)
slack_icon_urlstringURL of an image to use as the bot icon; takes priority over slack_icon_emoji
slack_icon_emojistringSlack emoji code for the bot icon, e.g. :robot_face: or :paul:

19. Entity Relationship Summary

agents ──────────────────────────────────────────────────────────────────────────┐
  │ (assigned_agent_id)                                                           │
  │ (uploaded_by_agent_id)                                                        │
  │ (author_agent_id — KB)                                                        │
  │                                                                               │
customers ────────────────────────┐                                               │
  │ (customer_id)                 │                                               │
  │ (customer_id — participants)  │                                               │
  │                               │                                               │
  ▼                               ▼                                               ▼
tickets ◄──── ticket_participants  ──► customers           attachments ◄──── agents
  │                                                             │
  │◄── ticket_tag_map ──► tags                                  │◄── customers
  │◄── ticket_relations (self)                                  │
  │◄── replies ──────────────────────────────────► agents
  │         │                                   └─► customers
  │         │◄── attachments


knowledge_base_articles ──► knowledge_base_categories
knowledge_base_articles ──► agents (author)
knowledge_base_articles ──► tickets (source_ticket_id)

refresh_tokens ──► agents
refresh_tokens ──► customers

audit_log (no FK constraints — append-only, references by value)

ticket_number_sequences (standalone counter table, no FK)
settings (standalone key/value store, no FK)
agent_notifications ──► agents
imap_accounts (standalone — tag_id references tags but no FK defined)

20. Indexes and Performance Notes

Covering indexes for common queries

The ticket list query (GET /api/tickets) filters by deleted_at IS NULL and optionally by status and assigned_agent_id. The composite indexes idx_tickets_deleted_status and idx_tickets_deleted_agent are designed to cover these.

knowledge_base_articles has a FULLTEXT index on (title, body_html). Queries use MATCH(...) AGAINST(? IN BOOLEAN MODE) which supports +word, -word, and prefix word* syntax.

Prefix indexes on VARCHAR(512)

Message-ID fields (original_message_id, raw_message_id) are indexed with a 191-character prefix ((191)) — the maximum for a single-column index on utf8mb4 without changing innodb_large_prefix.

Soft-delete pattern

tickets, customers, and knowledge_base_articles use deleted_at DATETIME NULL for soft deletes. All queries must include WHERE deleted_at IS NULL (or equivalent) to exclude deleted rows. The idx_*_deleted indexes make this efficient.

Character set

All tables use utf8mb4 with utf8mb4_unicode_ci collation, which correctly handles emoji, CJK characters, and case-insensitive email comparisons in the unique indexes.

Foreign key checks

schema.sql wraps everything in SET FOREIGN_KEY_CHECKS = 0 / SET FOREIGN_KEY_CHECKS = 1 to allow tables to be created in any order. This is safe for initial schema creation only — normal application operation relies on FK enforcement.