Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
49a3c2d
Enforce client scoping on API expense reads and record updates
johnnyq Aug 6, 2026
8338359
Add clientScopeSql helper for client-scoped list queries
johnnyq Aug 6, 2026
ddd9c2b
clientScopeSql sweep whicn now removed the extra unessesary client joins
johnnyq Aug 6, 2026
eb2cc3d
Update Contributing with the new clientScopeSql function
johnnyq Aug 6, 2026
9324d08
Drop more dead client left joins big saver is the badge counts
johnnyq Aug 6, 2026
77defcf
Global Search Query Optimization only returns columns needed
johnnyq Aug 6, 2026
b8c9d5b
Query Optimization: Select only needed columns instead of SELECT * on…
johnnyq Aug 6, 2026
b3744e9
More sweeps to replace select * with actual returned column names dra…
johnnyq Aug 6, 2026
108781d
Move more select * to column select for further optimization
johnnyq Aug 6, 2026
f8899e5
More Select * Queries convert
johnnyq Aug 6, 2026
abb724b
Removed Orphaned vars
johnnyq Aug 6, 2026
3cae520
Fix calendar event Delete bug
johnnyq Aug 6, 2026
64423cb
Fix Client PDF Export
johnnyq Aug 6, 2026
942fe41
Apply further gating to client pdf export
johnnyq Aug 6, 2026
605c781
Fix AI
johnnyq Aug 6, 2026
ac598f6
Fix system-generated ticket replies booking fake time worked
johnnyq Aug 6, 2026
10f0c9d
remove audit system time script
johnnyq Aug 6, 2026
db96766
Fix Error on Scheduling Tickets, Fixed Zapcal Path
johnnyq Aug 7, 2026
656aa95
Fix WebUI setup regression locking out after first step
johnnyq Aug 7, 2026
d484629
do not update ticket status in history if it is the same
johnnyq Aug 8, 2026
6b65c81
Fix contact delete leaving an orphaned portal user and anonymize not …
johnnyq Aug 8, 2026
46a63b1
Fix bulk recurring ticket priority change denying access to all non-a…
johnnyq Aug 8, 2026
04991a0
Fix ticket schedule cancellation never cancelling the calendar event
johnnyq Aug 8, 2026
6ff4541
Update changelog
johnnyq Aug 8, 2026
b903ee0
Bump version
johnnyq Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

This file documents all notable changes made to ITFlow.

## [26.08.1] Maint Release

### Upgrading to 26.08.1

Update the files from Settings > Update as normal. This release moves the database to 2.6.7 and the web updater completes it for you — the command line step that 26.08 required is not needed again.

### Breaking Changes and Notes

- Client access: agents with restricted client access now see records that have no client assigned. Previously this varied by page — unassigned tickets and projects were visible, unassigned expenses and credentials were not. It is now consistent everywhere.

### Bug Fixes
- Setup: fixed the wizard closing itself after the first user, which left new 26.08 installs stuck in a redirect loop between `/setup` and `/login.php`.
- API: tightened client scoping on the expense read and record update endpoints.
- Income: revenue rows now respect restricted client access.
- Client PDF Export: fixed the export producing a CSV file, and each section is now gated on the module that owns it.
- AI: fixed model creation, per-use-case model selection, configurable temperature, and error reporting.
- Ticket: system-generated replies no longer record time worked that was never worked.
- Ticket: fixed an error when scheduling a ticket.
- Ticket: cancelling a schedule now cancels the calendar event on the recipient's calendar.
- Ticket: history no longer records a status change when the status did not change.
- Recurring Ticket: bulk priority changes no longer deny access to agents who are not administrators.
- Contact: deleting a contact now removes the linked portal user, and anonymizing now redacts the phone number.
- Calendar: fixed event deletion.

### New Features & Updates
- Performance: queries now select only the columns they use instead of `SELECT *`, cutting memory use and query time across the app and especially in the crons.
- Performance: removed client joins that were only there for scoping — side nav badge counts are significantly faster.
- Client scoping: added a `clientScopeSql()` helper so list queries scope on the owning column instead of a joined `clients.client_id`.
- Contributing: documented the column-selection and client-scoping conventions.


## [26.08]

### Upgrading to 26.08
Expand Down
51 changes: 48 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ if (isset($_POST['edit_ticket_priority'])) {
### The `_model.php` pattern

Files named `agent/post/*_model.php` hold shared field collection/sanitization logic used by both the create and edit blocks of a module (e.g. `asset_model.php` is included by both `add_asset` and `edit_asset`). If create and edit share more than a couple of fields, use this pattern rather than duplicating. Model files carry the same `FROM_POST_HANDLER` guard and are excluded from the dispatcher's auto-load.

**`_model.php` is a reserved suffix.** The exclusion is a filename match, so a *handler* named `*_model.php` is silently never loaded — its form posts, nothing claims the request, and the user gets a blank page with no error anywhere. This is what happened to `admin/post/ai_model.php`, which is why the AI Models handler is now `admin/post/ai_models.php`. Name entity handlers around the suffix (`ai_models.php`, `users.php`, `api_keys.php`).

A POST that reaches the end of `admin/post.php` or `agent/post.php` without a handler claiming it is logged to App Logs as a `Request` warning, which is the fastest way to spot this class of mistake.

---

Expand Down Expand Up @@ -171,7 +175,25 @@ Everywhere else — anything under `agent/post/` — the call belongs in the blo

### 4. Client scoping is enforced, not assumed.

After loading a record, call `enforceClientAccess()` (optionally with the record's client ID) so technicians restricted to specific clients cannot touch other clients' data by editing an ID in the URL. Look at how `resolve_ticket` does it — including the "skip if the record has no client" case.
A user can be restricted to a subset of clients through `user_client_permissions`. Enforcing that has two halves, and a page usually needs both.

**One record — `enforceClientAccess()`.** After loading a record, call it (optionally with the record's client ID) so technicians restricted to specific clients cannot touch other clients' data by editing an ID in the URL. Look at how `resolve_ticket` does it.

**A list — `clientScopeSql()`.** Any query returning more than one row appends the fragment for that resource's own client column:

```php
$sql = mysqli_query($mysqli, "SELECT expense_id, expense_date, expense_amount, expense_description
FROM expenses
WHERE expense_archived_at IS NULL
" . clientScopeSql('expense_client_id') . "
ORDER BY expense_date DESC");
```

It returns `" AND ..."` or `""`, so it needs a `WHERE` to hang off — add `WHERE 1=1` if the query has no other condition. It is column-aware and takes an alias fine (`clientScopeSql('t.ticket_client_id')`). The API calls the same helper through the `apiClientScopeSql()` wrapper.

Scope on the resource's **own** column, not on a joined `clients.client_id`. Joining `clients` just to scope makes the filter depend on the join: with a `LEFT JOIN`, a row whose client column is `0` produces `NULL`, and `NULL IN (...)` is neither true nor false, so the row silently vanishes. If the query joins `clients` for `client_name`, keep the join for that — but still scope on the owning column.

**Records with no client (`0`) stay visible to restricted users.** `clientScopeSql()` emits `IN (0,...)` deliberately. Client restrictions partition *client* data, and a record belonging to no client is not any client's data to withhold. Do not hand-roll a variant that drops the `0` — the tree had accumulated several before this helper existed, disagreeing with each other, and reconciling them is what surfaced the inconsistency.

### 5. Escape on output.

Expand Down Expand Up @@ -206,8 +228,29 @@ Per [SECURITY.md](SECURITY.md) — never in a public issue.
---

## Conventions

**Database naming.** Every column is prefixed with the singular name of the entity it belongs to: `tickets.ticket_id`, `tickets.ticket_subject`, `clients.client_name`. This makes JOIN results unambiguous and is why queries can `SELECT *` across joins safely. New tables must follow it.

**Only technician-entered time is time worked.** `ticket_replies.ticket_reply_time_worked` is billable labour and feeds ticket totals, the technician and client time reports, project totals, invoicing and the API. A reply the *system* writes — assignment, priority change, merge, close, invoice/quote created, schedule edited, task completed or reopened — is an audit trail, not work, and records `'00:00:00'`. Only a value the technician actually typed goes in that column. Task completion estimates are planning information and stay on the task; they are never converted into time worked. `agent/ticket.php` hides the clock badge on a reply whose time is exactly `00:00:00`, so a zero renders as no time rather than as "0m".

**Database naming.** Every column is prefixed with the singular name of the entity it belongs to: `tickets.ticket_id`, `tickets.ticket_subject`, `clients.client_name`. This makes JOIN results unambiguous, so a `SELECT *` across joins is never *wrong*. New tables must follow it.

**Select the columns you use, not `*`.** Unambiguous is not the same as cheap. `SELECT *` across three joined tables fetches every column of all three, including the `*_notes` and `*_details` TEXT columns, and throws away whatever the page never renders. A search result list that shows five fields was pulling sixty. List the columns instead:

```php
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, client_name
FROM tickets
LEFT JOIN clients ON ticket_client_id = client_id
WHERE ticket_archived_at IS NULL
" . clientScopeSql('ticket_client_id') . "");
```

Two things follow from that:

- A query whose result only feeds `mysqli_num_rows()` needs no columns at all — write `SELECT 1`. Do not select a primary key "just in case": if the query joins two tables that both carry that column name, an unqualified `SELECT ticket_template_id` is an ambiguous-column error.
- Keep the join even when no column of the joined table survives into the `SELECT`, if the join is doing work — supplying a `WHERE` term, an `ORDER BY`, or the client column you scope on. Dropping a join is a separate decision from trimming the column list.

The trade is real and worth stating: `SELECT *` picks up new columns for free, an explicit list does not. Add a column to a table and every query that needs it must be updated by hand, and the failure mode is a blank field or a PHP 8 undefined-key warning rather than an error. That is the price of not fetching data nobody reads, and the project has decided to pay it on anything that loops or touches a TEXT column.

The exception is `api/v1/*/read.php`. Those endpoints hand the whole row to `read_output.php`, which serialises it straight into the JSON response — there the row *is* the output contract, so `SELECT *` is correct and trimming it would silently drop fields from every consumer.

The prefix is the entity name, which is usually but not always the singular of the table name. Where a table is named for its container rather than its row, the prefix follows the row: `calendar_events` → `event_*`, `asset_interfaces` → `interface_*`, `invoice_items` / `quote_items` → `item_*`, `rack_units` → `unit_*`, `user_roles` → `role_*`, `product_stock` → `stock_*`. Pick the prefix your columns will read best as and use it for every column in the table.

Expand All @@ -224,6 +267,8 @@ A single update run applies every pending migration in order, stopping at the fi
**After acting, log and notify.** State changes call `logAudit($type, $action, $description, $client_id, $entity_id)` for the audit trail. User-facing events may also call `appNotify()`. Fire `triggerCustomAction()` where a site might reasonably want a hook. Then call `flashAlert($message, $type)` and `redirect()` (defaults to the referer) rather than setting session keys or `header()` manually.

**Function names (post-rename).** Helpers were renamed for clarity in 2026; the old names **no longer exist** — code calling them fatals. If you're rebasing an old PR or following an old tutorial, translate: `sanitizeInput` → `escapeSql`, `nullable_htmlentities` → `escapeHtml`, `logAction` → `logAudit`, `flash_alert` → `flashAlert`, `customAction` → `triggerCustomAction`, `encryptLoginEntry`/`decryptLoginEntry` → `encryptCredentialEntry`/`decryptCredentialEntry`, `strtoAZaz09` → `toAlphanumeric`, `fetchUpdates` → `checkForUpdates`, `sanitize_url` → `escapeUrl`.

One removed **variable** deserves its own warning: the old `$access_permission_query` global is gone, replaced by `clientScopeSql()` (security rule 4). Unlike a removed function, it does not fatal — an undefined variable interpolates as an empty string, so a rebased query keeps running with **no client scoping at all**. Grep for it before rebasing anything that touches a list query.

**Helpers that fetch data return it raw.** If you add a `getXById()`-style helper, return the column value untouched and let callers escape it (security rule 1). Validating what the helper interpolates into its *own* query — table and column names, the id — is still the helper's job; that is query construction, not output escaping, and the two are not the same thing.

Expand Down
3 changes: 2 additions & 1 deletion admin/ai_models.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

require_once "includes/inc_all_admin.php";

$sql = mysqli_query($mysqli, "SELECT * FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id ORDER BY $sort $order");
$sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_provider_id,
ai_provider_name FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id ORDER BY $sort $order");

$num_rows = mysqli_num_rows($sql);

Expand Down
2 changes: 1 addition & 1 deletion admin/ai_providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

require_once "includes/inc_all_admin.php";

$sql = mysqli_query($mysqli, "SELECT * FROM ai_providers ORDER BY $sort $order");
$sql = mysqli_query($mysqli, "SELECT ai_provider_api_key, ai_provider_api_url, ai_provider_id, ai_provider_name FROM ai_providers ORDER BY $sort $order");

$num_rows = mysqli_num_rows($sql);

Expand Down
2 changes: 1 addition & 1 deletion admin/api_keys.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM api_keys
"SELECT SQL_CALC_FOUND_ROWS api_key_created_at, api_key_expire, api_key_id, api_key_name, api_key_secret, user_name FROM api_keys
LEFT JOIN users on api_key_user_id = user_id
WHERE (api_key_name LIKE '%$q%')
ORDER BY $sort $order LIMIT $record_from, $record_to"
Expand Down
2 changes: 1 addition & 1 deletion admin/app_logs.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM app_logs
"SELECT SQL_CALC_FOUND_ROWS app_log_category, app_log_created_at, app_log_details, app_log_id, app_log_type FROM app_logs
WHERE (app_log_type LIKE '%$q%' OR app_log_category LIKE '%$q%' OR app_log_details LIKE '%$q%')
AND DATE(app_log_created_at) BETWEEN '$dtf' AND '$dtt'
$log_type_query
Expand Down
7 changes: 4 additions & 3 deletions admin/audit_logs.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@

$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM logs
"SELECT SQL_CALC_FOUND_ROWS client_id, client_name, log_action, log_created_at, log_description, log_entity_id, log_id,
log_ip, log_type, log_user_agent, user_id, user_name FROM logs
LEFT JOIN users ON log_user_id = user_id
LEFT JOIN clients ON log_client_id = client_id
WHERE (log_type LIKE '%$q%' OR log_action LIKE '%$q%' OR log_description LIKE '%$q%' OR log_ip LIKE '%$q%' OR log_user_agent LIKE '%$q%' OR user_name LIKE '%$q%' OR client_name LIKE '%$q%')
Expand Down Expand Up @@ -87,7 +88,7 @@
<option value="">- All Clients -</option>

<?php
$sql_clients_filter = mysqli_query($mysqli, "SELECT * FROM clients ORDER BY client_name ASC");
$sql_clients_filter = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql_clients_filter)) {
$client_id = intval($row['client_id']);
$client_name = escapeHtml($row['client_name']);
Expand All @@ -103,11 +104,11 @@

<div class="col-sm-2">
<div class="input-group mb-3 mb-md-0">
<select class="form-control select2" name="user" onchange="this.form.submit()">

Check warning on line 107 in admin/audit_logs.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an "id" attribute to this input field and associate it with a label.

See more on https://sonarcloud.io/project/issues?id=itflow-org_itflow&issues=AZ_iZZu9xezt7eMSyBT-&open=AZ_iZZu9xezt7eMSyBT-&pullRequest=1294
<option value="">- All Users -</option>

<?php
$sql_users_filter = mysqli_query($mysqli, "SELECT * FROM users ORDER BY user_name ASC");
$sql_users_filter = mysqli_query($mysqli, "SELECT user_id, user_name FROM users ORDER BY user_name ASC");
while ($row = mysqli_fetch_assoc($sql_users_filter)) {
$user_id = intval($row['user_id']);
$user_name = escapeHtml($row['user_name']);
Expand Down
3 changes: 2 additions & 1 deletion admin/backup.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@

$backup_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_enabled, cron_job_daily_at FROM cron_jobs WHERE cron_job_name = 'backup'"));

$backups = mysqli_query($mysqli, "SELECT * FROM backups ORDER BY backup_created_at DESC LIMIT 100");
$backups = mysqli_query($mysqli, "SELECT backup_created_at, backup_error, backup_id, backup_size, backup_source, backup_status,
backup_type FROM backups ORDER BY backup_created_at DESC LIMIT 100");

$pending_count = intval(mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS c FROM backups WHERE backup_status IN ('Pending','Running')"))['c']);

Expand Down Expand Up @@ -224,7 +225,7 @@
</div>
<div class="form-group col-md-4">
<label>Keep at most (per type)</label>
<input type="number" class="form-control" name="config_backup_retention_count" min="1" value="<?= intval($config_backup_retention_count) ?>">

Check warning on line 228 in admin/backup.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an "id" attribute to this input field and associate it with a label.

See more on https://sonarcloud.io/project/issues?id=itflow-org_itflow&issues=AZ_iZZvnxezt7eMSyBUG&open=AZ_iZZvnxezt7eMSyBUG&pullRequest=1294
<small class="text-muted">Counted separately for each type. The newest of each is never deleted.</small>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion admin/backup_download.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

$backup_id = intval($_GET['backup_id']);

$sql = mysqli_query($mysqli, "SELECT * FROM backups WHERE backup_id = $backup_id AND backup_status = 'Complete' LIMIT 1");
$sql = mysqli_query($mysqli, "SELECT backup_file_name FROM backups WHERE backup_id = $backup_id AND backup_status = 'Complete' LIMIT 1");

if (mysqli_num_rows($sql) !== 1) {
http_response_code(404);
Expand Down
2 changes: 1 addition & 1 deletion admin/categories.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM categories
"SELECT SQL_CALC_FOUND_ROWS category_color, category_description, category_id, category_name FROM categories
WHERE category_name LIKE '%$q%'
AND category_type = '$category'
AND category_$archive_query
Expand Down
18 changes: 9 additions & 9 deletions admin/contract_templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@
$id = intval($row['contract_template_id']);
$name = escapeHtml($row['contract_template_name']);
$type = escapeHtml($row['contract_template_type']);
$freq = escapeHtml($row['contract_template_update_frequency']);
$sla_low_resp = escapeHtml($row['sla_low_response_time']);
$sla_med_resp = escapeHtml($row['sla_medium_response_time']);
$sla_high_resp = escapeHtml($row['sla_high_response_time']);
$sla_low_res = escapeHtml($row['sla_low_resolution_time']);
$sla_med_res = escapeHtml($row['sla_medium_resolution_time']);
$sla_high_res = escapeHtml($row['sla_high_resolution_time']);
$hourly_rate = escapeHtml($row['contract_template_hourly_rate']);
$after_hours = escapeHtml($row['contract_template_after_hours_hourly_rate']);
$freq = escapeHtml($row['contract_template_renewal_frequency']);
$sla_low_resp = escapeHtml($row['contract_template_sla_low_response_time']);
$sla_med_resp = escapeHtml($row['contract_template_sla_medium_response_time']);
$sla_high_resp = escapeHtml($row['contract_template_sla_high_response_time']);
$sla_low_res = escapeHtml($row['contract_template_sla_low_resolution_time']);
$sla_med_res = escapeHtml($row['contract_template_sla_medium_resolution_time']);
$sla_high_res = escapeHtml($row['contract_template_sla_high_resolution_time']);
$hourly_rate = escapeHtml($row['contract_template_rate_standard']);
$after_hours = escapeHtml($row['contract_template_rate_after_hours']);
$support_hours = escapeHtml($row['contract_template_support_hours']);
$net_terms = escapeHtml($row['contract_template_net_terms']);
$created = escapeHtml($row['contract_template_created_at']);
Expand Down
2 changes: 1 addition & 1 deletion admin/cron.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
$cron_jobs[$job['name']]['row'] = null;
}

$sql = mysqli_query($mysqli, "SELECT * FROM cron_jobs");
$sql = mysqli_query($mysqli, "SELECT cron_job_name FROM cron_jobs");
while ($job_row = mysqli_fetch_assoc($sql)) {
if (isset($cron_jobs[$job_row['cron_job_name']])) {
$cron_jobs[$job_row['cron_job_name']]['row'] = $job_row;
Expand Down
3 changes: 2 additions & 1 deletion admin/custom_links.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@

$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM custom_links
"SELECT SQL_CALC_FOUND_ROWS custom_link_icon, custom_link_id, custom_link_location, custom_link_name,
custom_link_new_tab, custom_link_order, custom_link_uri FROM custom_links
WHERE custom_link_name LIKE '%$q%'
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
Expand Down
2 changes: 1 addition & 1 deletion admin/database_updates/2.3.1.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");

// Migrate Payment Methods from Categories Table to new payment_methods table
$sql_categories = mysqli_query($mysqli, "SELECT * FROM categories WHERE category_type = 'Payment Method' AND category_name != 'Stripe' AND category_archived_at IS NULL");
$sql_categories = mysqli_query($mysqli, "SELECT category_name FROM categories WHERE category_type = 'Payment Method' AND category_name != 'Stripe' AND category_archived_at IS NULL");

while ($row = mysqli_fetch_assoc($sql_categories)) {
$category_name = escapeSql($row['category_name']);
Expand Down
Loading
Loading