Overview
The A 1 App Builders - Ticket System is a self-hosted, single-file-per-page PHP support ticketing application. It uses SQLite3 (no database server required), PHPMailer for transactional email, and a shared config.php that defines all constants, helpers, and the Database class.
There are three user roles — admin, agent, and user — each with their own auth guard and dashboard. Users register via email verification. Admins and agents are created by an admin.
🎫 Users
Self-register, verify email, submit tickets with category/priority/attachment, reply to their own tickets, track status changes.
🎧 Agents
View and reply to assigned tickets, add internal notes (hidden from users), change ticket status. Created by admin.
🛡 Admins
Full access — all tickets, user management, assign agents, change priorities/categories, settings, email toggles, color pickers.
📬 Emails
Four toggleable email events: new ticket, new reply, status change, assignment. Master on/off toggle plus per-event switches.
🗄 Database
SQLite3 only. Path: admin/database/ticket.db. WAL journal mode, foreign keys ON. Never use PDO in this project.
🔒 Security
CSRF on every POST form, bcrypt password hashing, email verification tokens, session timeout, secure cookie params, security headers.
/home/a1appbuilders/public_html/addons/ticketsystem/Web root:
/addons/ticketsystem (auto-detected in config.php)File Structure
Installation
Requirements
| Requirement | Minimum | Notes |
|---|---|---|
| PHP | 8.0+ | Uses named types, match, SQLite3 class |
| SQLite3 extension | Any | Enabled by default on most shared hosts |
| PHPMailer | 6.x | Installed via composer require phpmailer/phpmailer at site root |
| Filesystem write | — | admin/database/, admin/uploads/, assets/uploads/, logs/ must be writable |
Step-by-Step
1 · Upload
Upload the entire ticketsystem/ folder to /home/a1appbuilders/public_html/addons/
2 · PHPMailer
At site root, run composer require phpmailer/phpmailer. Or confirm vendor/autoload.php already exists.
3 · Run Setup
Visit /addons/ticketsystem/admin/database/setup.php?key=ticketsystem_setup_2025. Click Run Setup then Create Admin.
4 · Protect Setup
Delete or .htaccess-protect setup.php immediately after use.
5 · SMTP
Edit admin/includes/config.php: set SMTP_USER, SMTP_PASS (App Password), SMTP_HOST, SMTP_PORT.
6 · Login
Go to /addons/ticketsystem/login.php. Use the Admin tab with the credentials from Step 3.
Directory Permissions
chmod 755 admin/database/
chmod 755 admin/uploads/
chmod 755 assets/uploads/
chmod 755 logs/
Config & Constants
File: admin/includes/config.php — included by every PHP file in the system via require_once __DIR__ . '/../admin/includes/config.php' (or the equivalent relative path).
Identity
'ticketsystem''A 1 App Builders''support@a1appbuilders.com'Path Constants (server filesystem)
| Constant | Resolves to |
|---|---|
ADMIN_DIR | …/ticketsystem/admin/ |
PROGRAM_DIR | …/ticketsystem/ |
SITE_ROOT | …/public_html/ |
DATABASE_DIR | admin/database/ |
DB_PATH | admin/database/ticket.db |
ADMIN_UPLOADS_DIR | admin/uploads/ |
UPLOADS_DIR | assets/uploads/ |
LOGS_DIR | ticketsystem/logs/ |
Web Path Constants
| Constant | Value |
|---|---|
WEB_ROOT | /addons/ticketsystem (auto-detected) |
ADMIN_WEB_ROOT | /addons/ticketsystem/admin |
USERS_WEB_ROOT | /addons/ticketsystem/users |
UPLOADS_WEB | /addons/ticketsystem/assets/uploads |
PLUGINS_WEB | /plugins (Bootstrap, etc.) |
Other Constants
Database Schema
$db = new Database(); $db->conn->prepare(…) only. The constructor/destructor manage the connection lifecycle automatically.$db = new Database();
$stmt = $db->conn->prepare("SELECT * FROM tickets WHERE id=:id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
Tables
| Table | Purpose | Key Columns |
|---|---|---|
users | All accounts: admin, agent, user | id, name, email, password_hash, role, status, email_verified, verify_token, reset_token |
tickets | Support ticket headers | id, subject, category_id, priority_id, status, user_id, assigned_to, created_at, updated_at |
ticket_replies | Messages in each ticket thread | id, ticket_id, user_id, message, is_internal, attachment, created_at |
categories | Ticket categories | id, name, active |
priorities | Priority levels with colors | id, name, color (hex), active |
settings | Key/value config store | id, setting_key (UNIQUE), setting_value, updated_at |
activity_log | User and agent actions | id, username, action, created_at |
login_log | Login attempts (success + fail) | id, user_id, status, ip_address, user_agent, created_at |
admin_logs | Admin-only actions | id, admin_name, action, ip_address, created_at |
Ticket Status Values
Default Categories
Seeded by setup.php: General · Billing · Technical · Account · Feature Request · Other
Default Priorities
| Name | Color | Hex |
|---|---|---|
| Low | Green | #22c55e |
| Medium | Amber | #f59e0b |
| High | Orange | #f97316 |
| Critical | Red | #ef4444 |
Auth & Session
Session Keys (after login)
Session Timeout — Two-Layer Enforcement
1 · Cookie Lifetime
session_set_cookie_params(['lifetime' => SESSION_TIMEOUT]) expires the browser cookie after 30 minutes of inactivity.
2 · Server-Side Check
After session_start(), compares ts_last_activity timestamp. If expired: unsets session, destroys, starts fresh session.
if (isset($_SESSION['ts_last_activity'])
&& (time() - $_SESSION['ts_last_activity']) > SESSION_TIMEOUT) {
session_unset();
session_destroy();
if (session_status() === PHP_SESSION_NONE) session_start();
}
if (isset($_SESSION['ts_user_id'])) {
$_SESSION['ts_last_activity'] = time();
}
Email Verification Flow
Password Reset Flow
Roles & Guards
🛡 admin
- All tickets (all users)
- Update status / priority / category
- Assign tickets to agents
- Manage all users (activate, suspend, role change)
- Create users/agents/admins
- Settings — email, colors, categories, priorities
- Add replies + internal notes
🎧 agent
- View tickets assigned to them
- Reply to assigned tickets
- Add internal notes (hidden from users)
- Change ticket status
- Cannot change priority / assign / manage users
👤 user
- Submit new tickets
- View own tickets only
- Add replies (no edit or delete ever)
- See thread excluding internal notes
- Cannot reply to closed tickets
Auth Guard Functions
| Function | Allows | Redirects to |
|---|---|---|
require_ts_user() | Any logged-in user | login.php |
require_ts_agent() | agent + admin | login.php |
require_ts_admin() | admin only | admin/access_denied.php |
// Top of every admin page:
require_once __DIR__ . '/includes/config.php';
require_ts_admin(); // or require_ts_agent() for agent+admin pages
// Top of every user page:
require_once __DIR__ . '/../../admin/includes/config.php'; // adjust depth
require_ts_user();
Ticket Lifecycle
Submit Flow
Status Transitions
Any admin or agent can change status from admin_ticket_view.php (update panel) or the quick-modal on admin_tickets.php. A status change triggers a 'status_change' email to the user if that toggle is on.
When a user replies to a resolved ticket, the status automatically reverts to open.
User Restrictions
Replies & Internal Notes
| Type | is_internal | Visible to user | Who can add |
|---|---|---|---|
| User reply | 0 | Yes | User (ticket owner) |
| Agent/Admin reply | 0 | Yes | Agent or Admin |
| Internal note | 1 | No | Agent or Admin only |
Internal notes are excluded from the user's thread query via AND r.is_internal=0. They appear in the admin view with a dashed amber border and an "Internal" badge. They do not trigger user notification emails.
-- User-facing query (user_ticket_view.php)
SELECT r.*, u.name, u.role
FROM ticket_replies r
JOIN users u ON u.id = r.user_id
WHERE r.ticket_id = :tid AND r.is_internal = 0
ORDER BY r.created_at ASC;
Email Notifications
All email goes through send_ticket_email($db, $event, $data) in config.php, which checks the master toggle and per-event toggle before calling _dispatch_email() → send_mail() (PHPMailer SMTP).
Event Map
| Event key | Settings key | Recipient(s) | Trigger |
|---|---|---|---|
'new_ticket' | email_new_ticket | User (confirm) + Admin (alert) | Ticket submitted via user_new_ticket.php |
'new_reply' | email_new_reply | User (or agent if user replied) | Non-internal reply added by staff; or user reply notifies assigned agent/admin |
'status_change' | email_status_change | User | Status changed in admin_ticket_view or admin_tickets quick modal |
'assigned' | email_assigned | Agent | assigned_to changed to a new agent |
Master Toggle
Setting key: email_notifications. If '0', no emails are sent regardless of per-event toggles. All toggles are managed in Admin → Settings.
Email Template
If SITE_ROOT/assets/postoffice/email-template.html exists, it is used with placeholder substitution:
| Placeholder | Replaced with |
|---|---|
{{subject}} | Email subject line |
{{message}} | HTML message body |
{{fromEmail}} | ADMIN_EMAIL |
{{year}} | date('Y') |
If the template file does not exist, the raw HTML body is sent directly.
Attachments
Allowed Types
jpg · jpeg · png · gif · webp · pdf
Enforced by allowed_attachment($filename) which checks pathinfo(PATHINFO_EXTENSION) (lowercased). MIME type is not checked — extension is sufficient given the .htaccess blocks script execution in upload directories.
Size Limit
MAX_FILE_SIZE = 5,242,880 bytes (5 MB). Checked against $_FILES['attachment']['size'] before moving the file.
Storage Paths
| Uploader | Server path | Web path |
|---|---|---|
| User (new ticket / reply) | assets/uploads/ | UPLOADS_WEB |
| Agent / Admin (reply) | admin/uploads/ | ADMIN_WEB_ROOT/uploads/ |
Filename Sanitization
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$safe_name = 'u' . $me_id . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
Original filenames are never used. Stored filename is saved in ticket_replies.attachment.
Admin Panel
Pages
| Page | Path | Access | Purpose |
|---|---|---|---|
| Dashboard | admin/admin_dashboard.php | admin + agent | Stats (total/open/resolved/my-tickets), recent tickets table, pending users panel |
| All Tickets | admin/admin_tickets.php | admin + agent | Filterable ticket list. Quick status + assign modals. Agents see only assigned. |
| Ticket View | admin/admin_ticket_view.php?id=N | admin + agent | Full thread, reply form, internal note checkbox, update panel (admin only) |
| Users | admin/admin_users.php | admin only | Filter/search users, activate/suspend, change role, create user modal |
| Settings | admin/admin_settings.php | admin only | Email toggles, ambient color pickers, add/toggle/delete categories and priorities |
Quick Actions on admin_tickets.php
Both the Status and Assign buttons open Bootstrap modals with pre-filled selects. POST to the same page, process, then redirect with header('Location: …' . $_SERVER['QUERY_STRING']) to preserve filters.
Update Panel on admin_ticket_view.php
Visible to admins only (agents can only reply). Updates status, priority, category, and assigned agent in a single POST. Triggers status-change email if status differs from current, and assignment email if agent changed.
User Panel
| Page | Path | Purpose |
|---|---|---|
| Dashboard | users/user_dashboard.php | Stat cards (total/open/in-progress/resolved) + ticket table with status filter pills |
| New Ticket | users/user_new_ticket.php | Subject, category, priority, message textarea, optional attachment |
| Ticket View | users/user_ticket_view.php?id=N | Info strip, thread (no internal notes), reply form (no edit/delete), closed notice |
| Register | users/user_register.php | Name + email + password + confirm. Sends verification email. Status='pending' until verified. |
Ownership Guard
user_ticket_view.php queries with WHERE t.id=:id AND t.user_id=:uid — if the ticket doesn't belong to the logged-in user, they are redirected to user_dashboard.php. Users can never access other users' tickets.
Settings Reference
All settings are stored in the settings table as key/value pairs. Read via get_setting($db, $key, $default). Written via set_setting($db, $key, $value) (upsert).
| Key | Default | Description |
|---|---|---|
site_name | A 1 App Builders - Ticket System | Shown in page titles and emails |
email_notifications | 1 | Master email toggle — 0 disables all ticket emails |
email_new_ticket | 1 | Send confirm to user + alert to admin on new ticket |
email_new_reply | 1 | Notify user when agent replies |
email_status_change | 1 | Notify user when ticket status changes |
email_assigned | 1 | Notify agent when a ticket is assigned to them |
accent_color | #6366f1 | Primary indigo color |
accent_color2 | #06b6d4 | Cyan accent |
accent_warn | #f59e0b | Warning / yellow |
accent_success | #22c55e | Success / green |
Setup Tool
File: admin/database/setup.php — protected by a key param: ?key=ticketsystem_setup_2025. Returns 403 without it.
| Tool | POST field | What it does |
|---|---|---|
| 1 — Run Setup | run_setup | Creates all tables (IF NOT EXISTS), seeds default categories/priorities/settings. Safe to re-run. |
| 2 — Create Admin | create_admin | INSERT/UPSERT admin account. Sets role='admin', status='active', email_verified=1. |
| 3 — Activate User | activate_user | Sets status='active', email_verified=1 for any user by email. |
| 4 — Delete User | delete_user | Permanently deletes non-admin user by email. Requires confirmation checkbox. |
A recent-users table (last 20) is shown at the bottom of the page for quick verification.
Shared Functions
All functions live in admin/includes/config.php and are available everywhere config.php is included.
| Function | Purpose |
|---|---|
ts_is_logged_in(): bool | Returns true if ts_user_id and ts_role are set in session |
require_ts_user() | Redirect to login.php if not logged in |
require_ts_agent() | Redirect to login.php if not agent or admin |
require_ts_admin() | Redirect to access_denied.php if not admin |
csrf_token(): string | Return (or generate) the session CSRF token |
verify_csrf(string $token): bool | Constant-time compare of submitted vs session token |
sanitize($v): string | htmlspecialchars wrapper — use in all output |
post(string $k): string | trim($_POST[$k] ?? '') |
get(string $k): string | trim($_GET[$k] ?? '') |
get_setting($db, $key, $default): string | Read value from settings table |
set_setting($db, $key, $value): void | Upsert key/value into settings table |
log_activity($db, $user, $action): void | Insert into activity_log |
log_login($db, $uid, $status, $ip, $ua): void | Insert into login_log (uid may be null) |
log_admin($db, $admin_name, $action, $ip): void | Insert into admin_logs |
send_mail($to, $subject, $body): bool | Raw PHPMailer SMTP send |
send_ticket_email($db, $event, $data): void | Check toggles, build email, dispatch for ticket events |
_dispatch_email($tpl, $to, $subject, $msg): void | Internal — apply template and call send_mail |
ticket_status_badge(string $status): string | Return HTML badge for a ticket status |
priority_badge(string $name, string $color): string | Return colored pill badge for a priority |
allowed_attachment(string $filename): bool | Check extension against allowed list (jpg/png/gif/webp/pdf) |
Security
CSRF Protection
Every POST form includes <input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">. Every POST handler starts with if (!verify_csrf(post('csrf_token'))) { ... }. Token is stored in $_SESSION['ts_csrf'] and uses hash_equals() for constant-time comparison.
Security Headers
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Password Storage
password_hash($pw, PASSWORD_DEFAULT) on register and admin create. Verified with password_verify() on login. Never stored in plain text.
.htaccess Protection
| Directory | Protection |
|---|---|
admin/database/ | Deny from all — ticket.db cannot be downloaded |
assets/uploads/ | Block PHP/script execution — files served statically only |
admin/uploads/ | Block PHP/script execution — files served statically only |
logs/ | Deny from all — log files not publicly accessible |
SQL Injection Prevention
All queries use $db->conn->prepare() with bindValue() — never string interpolation of user input into queries. The only exception is integer IDs used directly in a small number of admin queries, which are always cast with (int) first.
Session Cookie Security
session_set_cookie_params([
'lifetime' => SESSION_TIMEOUT,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']), // HTTPS-only when on HTTPS
'httponly' => true, // Not accessible to JS
'samesite' => 'Lax',
]);
session_regenerate_id(true) is called on every successful login to prevent session fixation.
Logs & Error Handling
PHP Error Log
ticketsystem/logs/php_errors.log — set in config.php via ini_set('error_log', …). Display errors are OFF; log errors are ON.
Activity Log
activity_log table via log_activity($db, $username, $action). Captures logins, ticket submissions, replies, and admin actions.
Login Log
login_log table via log_login(). Records successes and failures with IP, user agent, and status strings like 'fail_password', 'fail_unverified'.
Admin Log
admin_logs table via log_admin(). Every update to a ticket, user role change, or settings save is recorded with admin name and IP.
try/catch. A logging failure or email error will never cause a 500 error or break the user flow — errors are written to the PHP error log only.Rules & Conventions
$db->conn->prepare() / bindValue() / fetchArray(SQLITE3_ASSOC) only.sanitize() before being echoed to the page.error_log(). Never let an exception surface to the user as a stack trace.Coding Style
| Rule | Example |
|---|---|
| Input from POST | post('key') — trims whitespace |
| Input from GET | get('key') — trims whitespace |
| Output to HTML | sanitize($val) or = sanitize($row['col']) |
| Integer IDs | Cast with (int) before use in queries |
| Redirect after POST | header('Location: …'); exit; — always include exit |
| Auth guard placement | Immediately after require_once config.php — first executable line |
| Timezone | America/Los_Angeles — set in config.php |