Skip to content

Snoozing Tickets Developer Guide

Ed Mozley edited this page Jul 28, 2026 · 1 revision

Snoozing Tickets β€” Developer Guide

How snooze is built: four columns, one shared predicate, and a definition of "asleep" that needs nothing to be running. Shipped as #933.

The user-facing description lives in the Tickets page β€” snoozing is small to use, which is why it doesn't have its own analyst page; almost everything interesting about it is under the hood.


1. πŸ“ The files involved

Colour key: 🧠 shared rule Β· πŸ”Œ API Β· ⌨️ client Β· 🎨 CSS Β· πŸ—„οΈ schema Β· πŸšͺ inbound door Β· βš™οΈ settings Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
🧠 includes/ticket_snooze.php The whole rule. The schema gate, the SQL predicates, preset β†’ UTC resolution, snooze / wake / wake-on-reply, and the reading-pane state read
πŸ”Œ api/tickets/snooze_ticket.php POST {ticket_id | ticket_ids[], preset, until_local?, reason?} β€” resolves the preset server-side and loops the ids
πŸ”Œ api/tickets/wake_ticket.php POST {ticket_id | ticket_ids[]} β€” the undo half; reports how many were actually asleep
πŸ”Œ api/tickets/get_emails.php Hides sleeping tickets from every folder; ?snoozed=1 lists only them, ordered by wake time
πŸ”Œ api/tickets/get_ticket_counts.php Same exclusion applied to every count, plus snoozed_count for the new folder
πŸ”Œ api/tickets/get_email_detail.php Adds email.snooze (null unless asleep right now) for the reading-pane banner
⌨️ assets/js/inbox.js The Snoozed folder, the preset flyout, the row pill, the banner, the custom-date dialog, snooze/wake actions
🎨 assets/css/inbox.css .email-snooze-pill, .merge-banner-snooze, .ctx-snooze-when β€” all from theme.css tokens
⌨️ tickets/index.php Menu entries, the dialog markup, and window.SNOOZE_WAKE_HOUR for the flyout labels
βš™οΈ tickets/settings/index.php, tickets/settings/manifest.php The start of the working day control and its snooze_wake_hour setting key
πŸ—„οΈ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php Four columns on tickets + ix_tickets_snoozed_until
πŸšͺ api/tickets/check_mailbox_email.php, api/self-service/reply_ticket.php, includes/webchat/webchat.php, includes/messaging/ingest.php The four inbound doors that wake a ticket
πŸ”Œ api/v1/resources/tickets.php, api/v1/lib/openapi_schemas.php Read-only snoozed_until on the REST API
🌍 lang/en/tickets.php, lang/pt-BR/tickets.php tickets.snooze.* and the two context-menu keys, EN and pt-BR in the same commit
πŸ“„ CHANGELOG.local.md, README.md, this wiki logged as #933

2. πŸ—„οΈ The schema β€” four columns, no table

`snoozed_until`  DATETIME NULL,      -- the instant it returns (UTC, like every datetime here)
`snoozed_at`     DATETIME NULL,      -- when it was put to sleep
`snoozed_by`     INT NULL,           -- which analyst
`snooze_reason`  VARCHAR(255) NULL,  -- optional "what are you waiting for?"
KEY `ix_tickets_snoozed_until` (`snoozed_until`)

No ticket_snoozes table. A ticket has at most one snooze at a time and the history that matters is already written to ticket_audit, so a table would buy a join and nothing else.

snoozed_by deliberately carries no foreign key, matching deleted_by beside it: deleting an analyst must not be blocked by the fact they once snoozed something.

Regenerate, don't hand-edit: includes/db_verify_indexes.php is produced by php scripts/gen_db_verify_indexes.php from freeitsm.sql. See the Database Verification Developer Guide.


3. ⏰ Waking is the clock, not a cron

A ticket is asleep if β€” and only if β€” snoozed_until > UTC_TIMESTAMP(). There is no scheduled job. Nothing clears the columns when the time passes; every query simply compares against now.

That is the single most important decision in the feature, and it was made for one reason: a ticket must come back on time on an install with the crons switched off β€” which is a normal state (they're opt-in, and a box that was asleep, a suspended scheduler or a failed run would otherwise bury a ticket past its wake time). A cron-based design fails quietly and the symptom is invisible, because the missing ticket looks exactly like a ticket nobody has raised.

The trade-off is that an expired snooze leaves a past value sitting in the column. That's harmless β€” every read tests the time β€” and it keeps the record of what was asked for. The consequence to remember when writing new code:

Never test snoozed_until IS NOT NULL to mean "asleep". It means "was snoozed at some point". Use the shared predicates below, or compare against UTC_TIMESTAMP() yourself.

Two places apply the same rule outside SQL, for the same reason:

  • get_emails.php nulls snoozed_until in the response when it has elapsed, so the client can't paint a "πŸŒ™ until last Tuesday" badge
  • apiSerializeTicket() reports snoozed_until only while it's still in the future

4. 🧠 The two predicates (and why one of them is 1=0)

snoozeHiddenSql(PDO $conn, string $alias = 't'): string   // hide sleeping tickets
snoozeOnlySql(PDO $conn, string $alias = 't'): string     // ONLY sleeping tickets

Both take the connection first β€” they consult the schema gate (Β§5). Both return a fragment with no placeholders, which is what lets them be appended to a query whose parameters are already ordered β€” the same property the trashed-ticket predicate relies on in get_ticket_counts.php, where the fragment lands in both WHERE and LEFT JOIN … ON clauses:

list($ttSql, $ttParams) = ticketTenantFilter($conn, $analystId, 't');
$ttSql .= " AND t.deleted_datetime IS NULL";
$ttSql .= snoozeHiddenSql($conn, 't');   // ← rides along, params untouched

Hide it from the counts as well as the list. A department badge reading 12 over a list showing 11 sends an analyst hunting for a ticket that is deliberately hidden, so the exclusion is applied to the tenant fragment that every count query already shares rather than to each query in turn.

The snoozed=1 view is the one exception to the "hidden everywhere" rule, and Trash is the other: get_emails.php skips the exclusion when listing the bin, so a ticket that was snoozed and then binned still appears where it was put last.


5. ⚠️ The schema gate β€” the one that keeps the inbox alive

get_emails.php and get_ticket_counts.php are the two busiest queries in the product, and both now name snoozed_until. On an install that has pulled the update but not yet run Database Verification, an unguarded reference doesn't degrade β€” it throws Unknown column and leaves the analyst staring at an empty ticket list. That is a far worse outcome than not having the feature.

So every fragment is gated:

function snoozeSchemaReady(PDO $conn): bool {
    static $ready = null;                     // once per request
    if ($ready !== null) return $ready;
    try {
        $ready = (bool) $conn->query("SHOW COLUMNS FROM `tickets` LIKE 'snoozed_until'")->fetch(PDO::FETCH_ASSOC);
    } catch (Exception $e) { $ready = false; }
    return $ready;
}

The degraded answers are chosen individually, and one of them is a trap worth naming:

Function Pre-upgrade Why
snoozeHiddenSql() '' Nothing can be asleep, so hide nothing β€” the queue is exactly what it was before the feature
snoozeOnlySql() ' AND 1=0' Not ''. An empty fragment would turn "only the sleeping ones" into "every ticket you can see" β€” the one failure mode worse than an empty folder
snoozeStateFor() null No banner
wakeTicket() false A truthful no-op, not an error
snoozeTicket() throws SNOOZE_NOT_READY The endpoint surfaces "run System β†’ Database Verification"

get_emails.php also swaps the two columns out of its SELECT list for NULL AS snoozed_until, NULL AS snooze_reason, so the shape of the response never changes.

Copy this pattern for any future column added to the inbox's hot queries. Most new columns in this codebase live behind a try/catch that yields a missing section (see how merged_away and split_out are read in get_email_detail.php); that works because those are additive blocks. A column named in the main list query has no such luxury.


6. πŸ• Presets resolve on the server, in the analyst's timezone

The client sends a key, never a timestamp:

three_hours | tonight | tomorrow | next_week | custom (+ until_local "Y-m-d H:i")

resolveSnoozeUntil() does the arithmetic in the analyst's display zone (Tz::current(), see Timezones and Time Handling) and converts to UTC at the very end β€” which is what makes "tomorrow at 9" survive a DST boundary. Doing it server-side means there is one definition of "tomorrow morning" rather than two that drift, and a page left open overnight can't snooze a ticket into the past.

It refuses rather than guesses: an unknown key, an unparseable date, a time that has already passed, and anything beyond SNOOZE_MAX_DAYS (730 β€” a typo of 2206 for 2026 should be refused, not honoured).

Tomorrow and Next week land on snooze_wake_hour (Tickets β†’ Settings β†’ General, default 9). Next week is next monday at that hour, which on a Monday means next Monday β€” "next week" that lands in ten minutes is a bug.

The client only labels the presets

window.SNOOZE_WAKE_HOUR is emitted by tickets/index.php purely so the flyout can render "Tomorrow Β· Wed 09:00". The label is a courtesy; the server is the authority.

One rule there is worth keeping when adding a preset:

{ key: 'tonight', label: '…', at: () => atHourLocal(18, 0),
  available: () => new Date().getHours() < 18 },

A preset is dropped when its name would stop being true. This was caught by a screenshot rather than by reasoning: at 20:24 the menu offered "This evening β€” Tomorrow 18:00", a row arguing with itself. After 18:00 the option simply isn't offered (In 3 hours covers the rest of the night). The server still rolls tonight forward a day rather than erroring, because the only way to reach it now is a page left open across 18:00, and giving that analyst the next evening beats an error.


7. πŸšͺ Waking on a customer reply β€” the four doors

wakeSnoozedTicketOnCustomerReply() is called at exactly the four places reopenTicketForCustomerReply() is called, and for the same reason that one is shared: "the customer replied" must mean the same thing whichever channel they arrived on.

Door File
Inbound email api/tickets/check_mailbox_email.php
Self-service portal api/self-service/reply_ticket.php
Web chat includes/webchat/webchat.php
Messaging (WhatsApp etc.) includes/messaging/ingest.php

Adding a new inbound channel? Add both calls together. A channel that reopens but doesn't wake produces a ticket that is technically open and still invisible, which is the worst of both.

The two rules are independent β€” a reply can wake without reopening (the ticket was never closed) or reopen without waking (it was never asleep). Waking is non-fatal by design: a customer's message must never be lost because waking failed.

Only a customer's reply wakes a ticket. An analyst replying to a sleeping ticket is a deliberate act, and a status change isn't the event the snooze was waiting for β€” so neither disturbs it. Narrow and predictable beats clever here.


8. 🚫 What snooze deliberately doesn't touch

  • SLA. The clock keeps running. Pausing it would let a desk snooze its way to 100% compliance; the customer's target isn't suspended because we decided to look away. A status flagged Pause SLA is the supported way to stop a clock β€” see SLA Management.
  • Search and the command palette. search_tickets.php and global_search.php are untouched. Snoozing hides a ticket from your folders, not from the product.
  • updated_datetime. Snoozing is the desk deciding not to act; bumping the ticket to the top of a last-updated sort on its way out of the list would be a lie. Same reasoning as renaming a subject (#930).
  • Status. A snoozed ticket keeps whatever status it had.

9. πŸ‘₯ On the ticket, not on the analyst

A mail client's snooze is personal, because an email belongs to one person. A ticket doesn't: "waiting on the user until Thursday" is a fact about the ticket, equally true for everyone who looks at it. So a snooze applies for the whole desk, the banner names who set it and why, and there is no per-analyst snooze table.

The consequence to be aware of: any analyst who can reach the ticket can wake it, and the audit trail is how you see who did what. Both endpoints are guarded by module access (requireModuleAccessJson('tickets')) plus analystCanAccessTicket() per id β€” the same rung as assign, status and rename, because deciding a ticket can't be worked until Thursday is everyday desk work rather than administration. See Roles and Permissions.

Bulk snooze loops the single-ticket path rather than issuing one UPDATE … WHERE id IN (…), so every ticket gets its own access check and its own audit row and one refusal doesn't take the others down with it β€” the rule established by bulk actions.

Every snooze and wake writes a ticket_audit row under the field name Snooze, including the automatic ones (Woken by customer reply).


10. βœ… How this was verified

php -l proves nothing here β€” a fatal is served as HTTP 200 β€” and grepping rendered HTML for a function name proves less.

  1. Two headless-Chrome harnesses over the real inbox.js: one for parse + unit checks (pill rendering, HTML escaping of the reason, banner construction, "next Monday is a Monday and is in the future"), one that drives the actual functions against a stub DOM β€” renderFolders(), updateActiveFolderClasses(), populateContextSnoozeSubmenu(), renderEmailList() β€” asserting the folder count, its position above Trash, the flyout's wiring and that the pill lands in the footer row. With a negative control: a deliberate syntax error appended to the file, confirming the harness reports it rather than passing silently.
  2. The endpoints against a forged analyst session: counts before/after (82 β†’ 81 total, 46 β†’ 45 unassigned, snoozed 0 β†’ 1), the ticket absent from the default list and present in ?snoozed=1, every validation path (past date, unknown preset, ten years out, unparseable, unauthenticated), bulk snooze, wake, and wake-when-already-awake returning woken: 0.
  3. The two behaviours no endpoint can show on its own: a snooze rewritten to one second ago returning to the queue with nothing having run, and wakeSnoozedTicketOnCustomerReply() bringing a three-day snooze straight back β€” with the audit trail printed to confirm the story it tells.
  4. The pre-upgrade path on a scratch database holding a tickets table without the columns: snoozeSchemaReady() false, the queue query still returning every ticket, and the Snoozed folder returning 0 and not everything β€” the 1=0 case from Β§5, proved rather than assumed.
  5. Screenshots of the real UI in light and dark, via a temporary same-origin page that adopts the session (the app's own :focus-within rule opens the flyout for a real .focus(), so nothing about the CSS was faked). This is what caught the "This evening β€” Tomorrow 18:00" bug in Β§6.
  6. D005 (endpoint permission coverage) confirming both new endpoints land on Module access: tickets and raise no new findings, plus the OpenAPI self-check, both db_verify drift self-checks, and an EN ↔ pt-BR key-parity check.

⚠️ Headless gotcha: --virtual-time-budget freezes CSS transitions, so the shared .modal rule (visibility over 0.28s) computes as hidden forever and modal screenshots come out with no footer buttons. It affects every dialog in the app equally β€” verify modal state by probing it and use an existing modal as the control, rather than chasing pixels.


11. Extending it

  • A new preset β€” add it to SNOOZE_PRESETS in inbox.js (with an available() if its name is time-sensitive) and a case in resolveSnoozeUntil(). The client key and the server case must match; the client can never send a time.
  • A new inbound channel β€” call wakeSnoozedTicketOnCustomerReply() next to reopenTicketForCustomerReply() (Β§7).
  • Snooze from the REST API β€” deliberately not built. snoozed_until is exposed read-only; making it writable means deciding what a preset means to a caller with no timezone, which wants a design conversation first.
  • Per-analyst snooze β€” would be a new table, not a new column, and a different feature (Β§9).

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally