Skip to content

Admin Task Management ​

Flow ID: AD-55 | Module(s): auth | Complexity: Medium Last Updated: 2026-09-29 — admin_menu.php/config.php citations re-anchored (4.124.0 resync); 2026-09-14 — routes.php citation moved again to :482-487 after commit 61064a9f7c (#633) added 5 lines to the blog/blog_comments_admin routes above the auth block; route definitions themselves remain unchanged. (Previously: 2026-08-31 — Advisable-com/ecommercen#703: application/config/routes.php citation moved to :477-482 after the legacy /api/priceTracking routes were restored above the auth block; route definitions themselves are unchanged)

Business Context ​

The admin task management system provides a simple internal to-do feature for logged-in admin users. It lives entirely inside the auth HMVC module and is exposed through three list views (all tasks, assigned to me, created by me), an add/edit form, and a Vue "bell" component in the admin header that surfaces a badge count of overdue tasks.

The feature is meant as a lightweight internal coordination tool — tasks are not visible to storefront customers, are not linked by foreign key to any other business entity (no orders, no customers, no products), and the upstream platform does not ship any notification, reminder, or recurrence mechanism for them. Bulk product import jobs repurpose the table as a cheap notification queue so that, once an import finishes, the admin bell lights up with a summary row.


API Reference ​

REST Endpoints ​

No REST API. There is no modern domain layer or REST controller for tasks — a grep of src/Domains/ and src/Rest/ returns only the unrelated DeferredTaskRunner (a post-response work queue). All task CRUD is served by the legacy admin interface.

Legacy Admin Routes ​

Routes are registered in application/config/routes.php:482-487:

php
$route['auth'] = 'auth';
$route['auth/tasks/(:num)'] = 'auth/tasks/$1';
$route['auth/(.+)'] = 'auth/$1';
$route['(\w{2})/auth'] = 'auth';
$route['(\w{2})/auth/tasks/(:num)'] = 'auth/tasks/$2';
$route['(\w{2})/auth/(.+)'] = 'auth/$2';

The dedicated numeric-offset route for tasks exists so that the (:num) match does not collide with the catch-all (.+) rewrite. All endpoints live on Adv_auth in ecommercen/auth/controllers/Adv_auth.php; the application-level application/modules/auth/controllers/Auth.php:1-7 is an empty subclass.

URLController methodFile:lineHTTPDescription
auth/tasks[/{offset}]tasks($offset = 0)Adv_auth.php:196-228GET (POST for filter)All-tasks list. Supervisor-only (ADVISABLE/ADMIN). NOT filtered to current user.
auth/myTasks[/{offset}]myTasks($offset = 0)Adv_auth.php:230-267GET (POST for filter)Tasks assigned to me (forces assignee_id = uid). Open to any admin; self-scoped.
auth/tasksTo[/{offset}]tasksTo($offset = 0)Adv_auth.php:269-306GET (POST for filter)Tasks created by me (forces creator_id = uid). Open to any admin; self-scoped.
auth/addTaskaddTask()Adv_auth.php:308-351GET form / POST saveFields: assigneeId, dueDate, title, description.
auth/editTask/{id}editTask($taskId)Adv_auth.php:353-401GET form / POST saveOwnership check via canModifyTask() before serving or saving the form.
auth/taskDelete/{id}taskDelete($taskId)Adv_auth.php:403-414POSTInline form + taskCsrf token. authorizeTaskMutation() guard.
auth/taskCompleted/{id}taskCompleted($taskId)Adv_auth.php:416-427POSTInline form + taskCsrf token. authorizeTaskMutation() guard.
auth/taskUncompleted/{id}taskUncompleted($taskId)Adv_auth.php:429-440POSTInline form + taskCsrf token. authorizeTaskMutation() guard.
auth/resetTasksIndexresetTasksIndex()Adv_auth.php:470-475GETClears the tskSearch session key and redirects.
auth/getUserTasksgetUserTasks()Adv_auth.php:477-483GET (AJAX, JSON)Bell-count endpoint; capped at 20 results.

The current stub used to describe /auth/tasks as "tasks created by the current user" — that is wrong. tasks is the all-tasks list, tasksTo is "tasks I created", and myTasks is "tasks assigned to me".


Code Flow ​

Listing (all three views) ​

All three list endpoints share the same view (application/views/admin/auth/tasks_list.php) and the same filter pipeline:

Adv_auth::tasks() | myTasks() | tasksTo()
  |
  +--> tasks():    allowRole([ADVISABLE, ADMIN]) → error_401()  (Adv_auth.php:200-202)
  |
  +--> setTaskRedirectUrl('auth/<view>', $offset)         (Adv_auth.php:560-566)
  +--> setSearchTerms()                                    (Adv_auth.php:485-558)
  |     |
  |     +--> reads/writes session key tskSearch
  |
  +--> myTasks:  force $where['assignee_id'] = uid
  |   tasksTo:   force $where['creator_id']  = uid
  |   tasks:     no ownership filter (supervisor-only gate at top)
  |
  +--> Adv_tasks_model::getAll* ($where, $limit, $offset)
  +--> Adv_tasks_model::countAll*                          (used for pagination)
  +--> taskCsrfToken() → $this->render['taskCsrf']        (Adv_auth.php:599-608)
  +--> Render admin/auth/tasks_list

Creating a task (addTask, Adv_auth.php:308-351) ​

addTask()
  |
  +--> form_validation rules from addTaskValidation() (Adv_auth.php:621-629)
  |     |
  |     +--> only `title` is `required`; all other fields `trim`-only
  |
  +--> if invalid  -> render admin/auth/addTask with errors
  +--> if valid    -> build task data:
  |     assignee_id  = post('assigneeId') ?: session uid
  |     creator_id   = session uid
  |     created_at   = now()
  |     due_date     = post('dueDate') ?: null
  |     title        = post('title')
  |     description  = post('description')
  |
  +--> Adv_tasks_model::addTask($taskData)
  +--> afterAddTask()                                      (Adv_auth.php:683-686 — empty hook)
  +--> redirect to tskPageUrl

Editing (editTask, Adv_auth.php:353-401) ​

Loads task by id, checks ownership via canModifyTask($task) (Adv_auth.php:363-365) — returns error_401() if the check fails. On POST runs editTaskValidation (Adv_auth.php:638-646), saves via Adv_tasks_model::updateTask($taskId, $taskData), then invokes the empty afterEditTask hook. Supervisor roles (ADVISABLE/ADMIN) may edit any task; other admins may only edit tasks where creator_id or assignee_id matches their session uid.

Complete / uncomplete / delete ​

All three now require a POST submission carrying the per-session taskCsrf token. They share the authorizeTaskMutation($taskId) guard (Adv_auth.php:448-468) before reaching the model:

authorizeTaskMutation($taskId)         (Adv_auth.php:448-468)
  |
  +--> verifyTaskRequest()             (Adv_auth.php:615-619)
  |     +--> method == 'post'  AND  hash_equals(session taskCsrf, post taskCsrf)
  |     +--> failure → error_401(), return null
  |
  +--> tasks_model->getBy('id', $taskId)
  |     +--> not found → set error, redirect, return null
  |
  +--> canModifyTask($task)            (Adv_auth.php:581-590)
  |     +--> supervisor (ADVISABLE/ADMIN) → true
  |     +--> uid == creator_id || uid == assignee_id → true
  |     +--> otherwise → error_401(), return null
  |
  +--> return $task
MethodFile:lineModel callHook fired
taskDeleteAdv_auth.php:403-414deleteTask($taskId)afterTaskDelete
taskCompletedAdv_auth.php:416-427completeTask($taskId, now)afterTaskCompleted
taskUncompletedAdv_auth.php:429-440unCompleteTask($taskId)afterTaskUncompleted

Model operations (ecommercen/auth/models/Adv_tasks_model.php) ​

MethodDescription
getAll($where, $limit, $offset)All tasks with search/filter.
getAllByAssignee($assigneeId, $where, $limit, $offset)Tasks assigned to a specific user.
getAllByCreated($creatorId, $where, $limit, $offset)Tasks created by a specific user.
addTask($taskData)Insert.
updateTask($taskId, $taskData)Update.
deleteTask($taskId)Hard delete.
completeTask($taskId, $completedAt)Stamps completed_at.
unCompleteTask($taskId)Clears completed_at.
countAll($where)Counts via count_all_results(), no row materialisation (see Known Issues #13).
fixOrder()Ordering (see Task Lifecycle).
fixWhere($where)Where-clause translation (see Search & Filter).

Domain Layer ​

None. No src/Domains/**/Task* entity, repository, or service. No src/Rest/**/Task* controller or resource. The only match for "Task" under src/ is src/DeferredTask/DeferredTaskRunner.php, which is unrelated (a post-response work queue). application/config/container/deferred_task.php references that runner, not the admin task list.


Architecture ​

ComponentPathPurpose
Adv_authecommercen/auth/controllers/Adv_auth.phpHosts all task methods (:196, :230, :269, :308, :353, :403, :416, :429, :470, :477).
Auth (subclass)application/modules/auth/controllers/Auth.php:1-7Empty override shell.
Adv_tasks_modelecommercen/auth/models/Adv_tasks_model.php168-line CRUD model, table tasks, extends Adv_base_model. Loaded in Adv_auth::__construct at Adv_auth.php:8.
Tasks_model (subclass)application/modules/auth/models/Tasks_model.php:1-7Empty override shell.
List viewapplication/views/admin/auth/tasks_list.phpShared by all three list endpoints.
Add viewapplication/views/admin/auth/addTask.phpDatepicker + TinyMCE description + chosen-select assignee.
Edit viewapplication/views/admin/auth/updateTask.phpSame layout as add form.
Routesapplication/config/routes.php:482-487Route definitions.
Admin menuapplication/config/admin_menu.php:378-398Menu entries (see Security gap #2).
Controller baseapplication/core/Admin_c.php → ecommercen/core/Adv_admin_controller.php:27-72Enforces "must be logged-in admin" in constructor.

Data Model ​

Defined only in database/initial/initial.sql:2239-2251. There is no Phinx migration for this table — the misleadingly-named database/migrations/20250324121720_migration_task.php is actually a chmod helper for cache/delete.sh and has nothing to do with tasks.

sql
CREATE TABLE `tasks` (
    `id`           int(11)  NOT NULL AUTO_INCREMENT,
    `creator_id`   int(11)  NOT NULL,
    `assignee_id`  int(11)  NOT NULL,
    `created_at`   datetime NOT NULL,
    `completed_at` datetime DEFAULT NULL,
    `due_date`     datetime DEFAULT NULL,
    `title`        text     NOT NULL,
    `description`  text     DEFAULT NULL,
    PRIMARY KEY (`id`) USING BTREE,
    KEY `creator_id` (`creator_id`) USING BTREE,
    KEY `assignee_id` (`assignee_id`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8;
ColumnTypeNullDefaultNotes
idINT(11) AUTO_INCREMENTNo—Primary key.
creator_idINT(11)No—Implicit FK to users.id (no DB constraint).
assignee_idINT(11)No—Implicit FK to users.id. NOT NULL at schema level, but the controller accepts an empty POST and falls back to the session uid.
created_atDATETIMENo—Set explicitly by the app on insert.
completed_atDATETIMEYesNULLNULL = open, any datetime = done.
due_dateDATETIMEYesNULLOptional deadline.
titleTEXTNo—Stored as TEXT (not VARCHAR).
descriptionTEXTYesNULLPlain text / raw HTML from TinyMCE.

Indexes: PRIMARY on id, non-unique creator_id, non-unique assignee_id. No composite indexes on hot paths (completed_at, due_date).

Companion tables: none. There is no task_assignees, no task_comments, no task_attachments, no tasks_mui. Tasks are single-assignee, single-table, no translations.

Foreign keys: none at the database level. creator_id and assignee_id are bare indexed integer columns; deleting a user from users leaves orphan tasks behind.

Audit trail: none. There is no updated_at, no completed_by_id, no deleted_at, no record of who mutated the row. (The previous version of this document listed an updated_at column — that was incorrect.)


Task Lifecycle ​

States are binary. A task is either open (completed_at IS NULL) or done (completed_at IS NOT NULL). There is no "in progress", no priority field, no status enum, no assignee change history.

Status filter (inverted!) ​

Adv_tasks_model::fixWhere() (Adv_tasks_model.php:51-57) translates the session filter value into SQL:

tskSearch['status']SQL appliedMeaning in UI dropdown (tasks_list.php:59-60)
'true'completed_at IS NULLUncompleted / open
'false'completed_at IS NOT NULLCompleted / done
'all' or unsetno filterAll statuses

The inversion is intentional and matches the view dropdown, but it is a trap for anyone reading the model directly.

Overdue detection ​

  • List view (tasks_list.php:137-142): row is rendered with bg-warning if due_date IS NOT NULL AND now() > due_date AND completed_at IS NULL. Completed tasks get bg-success and win.
  • Vue bell: uses a much looser rule — see TasksBell Vue Widget.

Recurrence / reminders ​

None. No recurrence_rule column, no cron sweep of the table, no reminder emails, no webhook fired at the due date. Once completed, the row is frozen with a completed_at timestamp.

Ordering ​

Adv_tasks_model::fixOrder() (Adv_tasks_model.php:24-31) applies:

  1. completed_at IS NOT NULL ASC — open tasks float above closed ones.
  2. completed_at DESC — most recently completed first among closed.
  3. created_at DESC — newest first among open.

Pagination is 20 rows per page (Adv_base_controller::$limit = 20).


Search & Filter ​

Filter criteria are session-persisted in the key tskSearch via Adv_auth::setSearchTerms() (Adv_auth.php:485-558). The helper is reused across tasks, myTasks, tasksTo, and the Vue bell endpoint getUserTasks — see Bell Count Quirks.

The model translates tskSearch into SQL in Adv_tasks_model::fixWhere() (Adv_tasks_model.php:33-85):

UI fieldtskSearch keyColumnModel behavior
searchTitletitletitleLIKE %value%
(hidden, not exposed)descriptiondescriptionLIKE %value%
creatorId selectcreator_idcreator_idExact match
assigneeId selectassignee_idassignee_idExact match
searchStatus dropdownstatuscompleted_atInverted (see lifecycle)
createdDate datepickercreated_atcreated_atFull-day match
endDate datepickercompleted_atcompleted_atFull-day match
dueDate datepickerdue_datedue_dateFull-day match

Context rules:

  • On myTasks, the assigneeId select is rendered disabled (tasks_list.php:27) and the assignee_id filter is locked to the current user.
  • On tasksTo, the creatorId select is disabled (tasks_list.php:40) and creator_id is locked.
  • The Reset action hits /auth/resetTasksIndex (Adv_auth.php:470-475), which unsets the session key and redirects to the last list URL (tracked via setTaskRedirectUrl, Adv_auth.php:560-566, session key tskPageUrl).

Views ​

  • List view application/views/admin/auth/tasks_list.php — shared by tasks, myTasks, tasksTo. A single POST form at the top holds all filters. Tasks render in a Bootstrap table with a details modal per row.

    Per-row actions (complete, uncomplete, delete) are inline POST forms (tasks_list.php:246-272) with a taskCsrf hidden field (tasks_list.php:244), matching the modal controls (tasks_list.php:182-209). The edit button is a plain <a> GET anchor (tasks_list.php:262-266), which is correct — edit is a safe GET that redirects to the edit form with its own POST submission.

    The modal delete button (tasks_list.php:182-188) and complete/uncomplete buttons (tasks_list.php:191-209) are also inline POST forms with the same taskCsrf token. No action in the list view reaches the destructive endpoints via GET.

  • Add form application/views/admin/auth/addTask.php — dueDate (datepicker), title, description (TinyMCE mceEditor), assigneeId (chosen-select populated via allowedUsers($users, $roles) from ecommercen/helpers/auth_helper.php:213-225).

  • Edit form application/views/admin/auth/updateTask.php — identical layout.

The description and title fields are escaped on output with html_escape() at four sites: title as HTML text at tasks_list.php:154 (modal heading) and :229 (row link), and description via content_shorten($task->description, 400) at :179 (modal body) and again in the title attribute at :226 — the latter was the actual exploitable sink. See security gap #5.


TasksBell Vue Widget ​

The admin header ships a small Vue 2 + Vuex widget that polls the same backend for a badge count of "overdue" tasks.

Component — assets/admin/js/tasks/TasksBell.vue ​

vue
<template>
  <a :href="getMyTasksUrl()">
    <i :class="getBellClass()">
      <span>{{ getUserTasksWithDueDateExpired.length }}</span>
    </i>
  </a>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
  name: 'TasksBell',
  props: { siteUrl: String },
  computed: {
    ...mapGetters(['getUserTasksWithDueDateExpired'])
  },
  async mounted () {
    await this.$store.dispatch('fetchAllUserTasks')
  },
  methods: {
    getBellClass () {
      if (this.getUserTasksWithDueDateExpired.length > 0) {
        return 'mdi mdi-bell-ring'
      }
      return 'mdi mdi-bell'
    },
    getMyTasksUrl () { return this.siteUrl + 'auth/myTasks' }
  }
}
</script>

Vuex store — assets/admin/js/tasks/tasks.js ​

js
const store = new Vuex.Store({
  state: { allUserTasks: {}, namespaced: { namespace: 'tasks' } },
  getters: {
    getAllUserTasks (state) { return state.allUserTasks },
    getUserTasksWithDueDateExpired (state) {
      return filter(state.allUserTasks, e => e.due_date && !e.completed_at && new Date(e.due_date).getTime() < Date.now())
    }
  },
  mutations: { setAllUserTasks (state, tasksData) { state.allUserTasks = tasksData } },
  actions: {
    async fetchAllUserTasks ({ commit }) {
      try {
        axios.get('/auth/getUserTasks', {}).then((res) => {
          const data = res.data
          commit('setAllUserTasks', data)
        }, (error) => {
          console.log(error)
        })
      } catch (error) {
        console.log(error)
      }
    }
  }
})

new Vue({ el: '#tasks', store, config, locale, components: { TasksBell } })

Mount point — application/views/admin/head.php:80-86 ​

html
<li class="eshopAdminUrl adminTasksUrl admin-user-item" id="tasks" title="My Tasks">
    <div class="dropdown-head">
        <button class="dropbtn-head">
            <tasks-bell :site-url="'<?= site_url(); ?>'"></tasks-bell>
        </button>
    </div>
</li>

Bundle registration ​

  • webpack.mix.admin.js:165 — .js('assets/admin/js/tasks/tasks.js', 'public/ui/admin/dist/').vue()
  • application/views/admin/footer_js.php:2994 — bundle <script> include.

Backend endpoint it talks to ​

Adv_auth::getUserTasks() at Adv_auth.php:477-483 returns JSON. It calls Adv_tasks_model::getAllByAssignee($uid, $where, 20, 0) — hardcoded page size of 20, offset 0, and $where is whatever setSearchTerms() pulled from the tskSearch session key.


Bell Count Quirks ​

The Vue bell widget had three interacting behavioral quirks that made the badge count unreliable, enumerated as bugs 9-11 in Known Issues & Security Gaps. Bug 9 (completed tasks counted as overdue) is now fixed (#71); two quirks remain open — the hard cap of 20 (bug 10, tracked as #104) and pollution from the last list view's session filter (bug 11, tracked as #72).

The bell also only fetches once, in mounted(). No polling, no push, no websocket — the count only refreshes on a full page navigation.

There is no dropdown preview; clicking the bell simply hard-redirects to /auth/myTasks via siteUrl + 'auth/myTasks'.


Known Issues & Security Gaps ​

These gaps are serious enough to treat as a dedicated section rather than a TODO list.

  1. [FIXED #21] Per-endpoint RBAC — all-tasks list now gated; myTasks/tasksTo intentionally open but self-scoped. Before commit 0260023895, the only gate on any task endpoint was the constructor "must be logged-in admin" check. tasks() now opens with allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN], ...) → error_401() (Adv_auth.php:200-202), matching the admin-menu gating. myTasks and tasksTo remain open to any admin role — they are self-scoped to the session uid by forcing assignee_id/creator_id filters, so they cannot expose another user's data. Related open tracker: #66 (menu gating — the menu entry hide for auth/tasks is still cosmetic security even though the endpoint is now gated; a direct URL still reaches it for non-ADMIN roles, which is the intended new behaviour, but the menu logic and the endpoint RBAC still do not perfectly mirror each other).

  2. Menu-level gating is cosmetic security. application/config/admin_menu.php:378-398 shows auth/tasks only to [AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN]. The menu and the endpoint now agree on who can access the all-tasks list. However, auth/myTasks and auth/tasksTo are still exposed to everyone in the menu (empty roles array) and the endpoint, which is correct by design (self-scoped). Menu gating is not a security boundary in any case — see #66.

  3. [FIXED #22] Ownership check on edit, delete, complete, and uncomplete. Before commit 0260023895, none of these endpoints compared creator_id/assignee_id to the session uid. editTask now calls canModifyTask($task) (Adv_auth.php:363-365) immediately after loading the task, returning error_401() on failure. taskDelete, taskCompleted, and taskUncompleted all reach canModifyTask through the shared authorizeTaskMutation() guard (Adv_auth.php:448-468). A supervisor role (ADVISABLE/ADMIN) may modify any task; other admins are restricted to tasks where their uid matches creator_id or assignee_id (Adv_auth.php:581-590).

  4. [FIXED #23] Destructive actions now require POST + per-session CSRF token. Before commit 0260023895, taskDelete, taskCompleted, and taskUncompleted were GET routes reachable via plain <a> anchor tags — a single <img src="/auth/taskDelete/42"> was enough to destroy a task. These three methods now pass through authorizeTaskMutation(), which calls verifyTaskRequest() (Adv_auth.php:615-619): the request must be POST and carry a taskCsrf field matching the per-session token generated by taskCsrfToken() (Adv_auth.php:599-608). The token is lazily generated as bin2hex(random_bytes(16)), stored in session key taskCsrf, and passed to views as $this->render['taskCsrf']. All three list views render it as a hidden field in inline forms. Note: csrf_protection remains globally false in application/config/config.php:143; the taskCsrf mechanism is a task-specific synchronizer token, not a framework-level CSRF guard.

  5. [FIXED #24] Stored XSS in description and title — closed by escaping on output, not on input. The controller still saves post('description') and post('title') untouched — storage stays raw so application/views/admin/auth/updateTask.php:28's set_value('description', $task->description) keeps round-tripping pre-existing TinyMCE HTML in the edit form (CI3's set_value() already escapes for that context). The list view's actual sink was never :179 — content_shorten() declared at application/helpers/MY_text_helper.php:97 calls strip_tags() as its first transformation, so markup never reached the browser there. The real sink was tasks_list.php:226: the raw description was interpolated into a double-quoted title="..." HTML attribute, and a bare " in the stored value broke out of it (e.g. " onmouseover="alert(1)). A second sink was the raw title column, echoed as HTML text content at :154 (modal heading) and :229 (row link) — a <script>alert(1)</script> title executed directly, a simpler exploit than the attribute break-out; this sink was never named in the original issue and was folded into the same fix. All four sites now escape with html_escape(), with :179 gaining it as defence-in-depth on top of the pre-existing strip_tags. :179 passes the literal … character as content_shorten()'s third argument rather than relying on the helper's '&hellip;' entity default, so html_escape() can't double-encode it into a visible &hellip;; the truncation marker renders unchanged, and the shared helper's default is untouched for every other caller. The two description sites (:179, :226) pass html_escape($value, false) — double_encode = false — because description is stored as HTML source from TinyMCE (&amp;, &nbsp;, etc.) and re-encoding those entities would render them as literal visible text instead of the character they represent; the two title sites (:154, :229) keep the bare single-argument form, because title is stored as literal text from a plain <input name="title">, so double_encode = true correctly shows a literally-typed A&amp;B as-is rather than silently decoding it to A&B. Security is unaffected either way: double_encode only changes how an already-valid entity sequence's & is handled, and every literal <, >, ", ' is still escaped identically under both settings. No sanitizer dependency was added (HTMLPurifier/voku/anti-xss — neither is in composer.json/composer.lock): no render site displays the description as HTML (:179 strips tags, :226 is an attribute), so sanitizing on input would have bought nothing and the editor round-trip was already safe via set_value(). The blast radius is cross-role, not self-XSS: auth/myTasks and auth/tasksTo carry 'roles' => [] (application/config/admin_menu.php:375 and :382), open to every admin role, and the POSTed assigneeId is not server-validated (tracked separately as #67) — so a low-privilege admin can assign a payload-carrying task to a supervisor and have it execute in that supervisor's authenticated session. Fixing #67 narrows this but does not close it. Invariant — the task tooltip at tasks_list.php:226 must stay html: false. Bootstrap reads the tooltip content via $e.attr('title'), which returns the entity-decoded string, so under double_encode = false a description containing &lt;img src=x onerror=alert(1)&gt; (the normal way TinyMCE stores an author-typed <img ...>) reaches JavaScript as a live <img src=x onerror=alert(1)>. That is safe today only because the tooltip initializes with no options — $('[data-toggle="tooltip"]').tooltip() at application/views/admin/footer_js.php:3847 — and the bundled public/ui/plugins/bootstrap/3.3.7/js/bootstrap.min.js defaults tooltips to html: false, which injects the content via .text() rather than .html() regardless of how the attribute decoded; the anchor at :226 carries no data-html override, so it inherits that default. Bootstrap 3 honours a per-element data-html="true" override, and this codebase already uses it in three places — application/views/admin/orders/create.php:312, edit.php:292, repeat.php:285 (all <span>s with t()-translated titles) — so adding data-html="true" to the task tooltip, or switching its initialisation to html: true, would make entity-encoded markup in a description live again and reopen this stored XSS.

  6. NOT NULL violations possible under edge cases. addTask writes assignee_id = (!empty(post('assigneeId'))) ? post('assigneeId') : session->userdata('uid') (Adv_auth.php:325-327). assignee_id is NOT NULL at the schema level, but if the session uid is missing and the POST field is empty, the insert attempts to write an empty string into a NOT NULL INT column. There is no server-side check that the POSTed assigneeId corresponds to an existing user.

  7. No audit trail, no soft delete. There is no updated_at, no completed_by_id, no deleted_at, no log of who mutated the task. Once a task is deleted it is gone with no trace of which admin clicked the button.

  8. No notifications of any kind. Assignment changes send no email, fire no in-app notification, and do not update the target user's bell until the target's browser performs a full page navigation. The afterAddTask, afterEditTask, afterTaskDelete, afterTaskCompleted, and afterTaskUncompleted hooks at Adv_auth.php:678-701 are empty stubs explicitly designed for client repos to override — the upstream platform ships zero notification implementation.

  9. [FIXED #71] Bell: completed tasks no longer count. The getter getUserTasksWithDueDateExpired (assets/admin/js/tasks/tasks.js:25-27) checked only due_date against Date.now(), so a completed task whose due date had passed still contributed to the badge count until the next full page navigation dropped it out of the top 20. The getter now adds a !e.completed_at guard alongside the existing e.due_date guard (the #70 fix, preserved unchanged), so a completed task is excluded from the overdue count regardless of its due date. TasksBell.vue needed no change — it only renders the getter's length and picks the bell icon from it, so the shared getter fix corrects both the count and the icon.

  10. Bell: hard cap of 20 silently clamps. The JSON endpoint getUserTasks calls getAllByAssignee(uid, where, 20, 0) (Adv_auth.php:477-483) — the bell can never show more than 20 even if the user has hundreds of overdue tasks.

  11. Bell: polluted by the last list filter. getUserTasks routes through the same setSearchTerms() helper as the three list views, so the tskSearch session key is shared (Adv_auth.php:485-558). If the admin just browsed myTasks with status filter set to "completed", the bell query inherits that filter and counts only completed tasks.

  12. [FIXED #73] Copy-paste hook bug: taskCompleted and taskUncompleted fire afterTaskDelete. taskCompleted (Adv_auth.php:416-427) and taskUncompleted (Adv_auth.php:429-440) now call dedicated afterTaskCompleted($taskId) and afterTaskUncompleted($taskId) hooks (Adv_auth.php:693-701) instead of the shared afterTaskDelete. taskUncompleted's flash message was also corrected to t('auth.task.success.uncompleted', [$taskId]) (previously it incorrectly reused the "completed" message). Both hooks are empty stubs, matching the pattern of the other task/user lifecycle hooks.

  13. [FIXED #74] countAll now counts via COUNT(*) instead of num_rows(). Adv_tasks_model::countAll() (Adv_tasks_model.php:98-103) previously loaded the full filtered result set into PHP just to call num_rows() on it and discard the rows. It now keeps the same fixJoins()/fixWhere($where) calls — so the counted set is still structurally identical to the one getAll() pages over — and returns $this->db->count_all_results($this->table), which compiles a COUNT(*) query honouring those joins and where clauses (and, per CI3, drops any ORDER BY) instead of selecting and materialising every matching row.

  14. [FIXED #552] Ten more unescaped sinks — session-persisted search-term XSS — but the underlying CSRF gap is only now inert, not closed. #24 escaped only title/description; ten other sinks in the same view were explicitly out of scope for that fix and remained raw: the search term (tasks_list.php:21, a double-quoted value attribute), the assignee/creator filter <option> usernames (:31, :44), the three date-filter value attributes (:73, :81, :89), and the creator/assignee usernames in the modal (:171, :175) and table row (:232, :233). All ten now wrap the value in bare html_escape() — these are literal-text values (search terms, usernames, dates), not stored HTML like description, so the double_encode = false treatment #24 used for description does not apply here. The search term was the serious one because it is session-persisted, not merely reflected: Adv_auth.php:493 captures the raw POST, :524 writes it into the tskSearch session key, :487 re-reads it on every later request, and :529 hands it to the view; it is cleared only via auth/resetTasksIndex (:473). A planted payload therefore fired on every subsequent task-list load until the admin explicitly reset the search. Residual gap — not closed by #552: the search POST form still carries no taskCsrf token (unlike the destructive-action forms fixed by #23), and both csrf_protection and global_xss_filtering are false (application/config/config.php), so an attacker-hosted auto-submitting cross-origin POST can still plant an arbitrary value into a visiting admin's tskSearch session key. The planted value is now inert on render (escaped on every read site), but the session remains plantable — adding a CSRF token to the search form was explicitly out of scope for #552 and needs its own follow-up issue.

Form validation ​

addTaskValidation() (Adv_auth.php:621-629) and editTaskValidation() (Adv_auth.php:638-646) mark only title as required; everything else is trim-only. There is no min/max length check, no assignee-existence check, and no due-date format validation.

Open trackers ​

  • #66 — menu gating vs endpoint RBAC alignment
  • #311 — split task management out of Adv_auth into its own controller
  • No tracker yet — CSRF token for the task-list search POST, to close the residual plantability gap left open by #552 (see security gap #14)

Bulk Import Jobs as Notification Queue ​

Outside the admin UI, four bulk product import jobs write rows directly into the tasks table and use it as a poor-man's notification queue. In every case creator_id = assignee_id = customerId and due_date = date('Y-m-d H:i:s') (i.e. now), so the bell lights up the instant the job finishes.

Job fileLineTitle/description source
ecommercen/job/libraries/AdvInsertProductsFromFile.php:49-56Import summary (counts of inserted/failed products).
ecommercen/job/libraries/AdvUpdateProductsFromFileByBarcode.php:49Same pattern.
ecommercen/job/libraries/AdvUpdateProductsFromFileByProductCode.php:47Same pattern.
ecommercen/job/libraries/AdvUploadImagesFromZipFile.php:33-40Hardcoded Greek title/description at :30-31 — not translated.

Because the generated due_date is always "now", these rows feed directly into the bell's overdue count the moment they land.

Dead load: ecommercen/audit/controllers/Adv_audit.php:10 loads tasks_model in its constructor but never calls it — leftover copy-paste, not an actual integration point.


Cross-References to Business Entities ​

Tasks are not linked to any other business entity. There is no FK (or implicit column) pointing at orders, customers, products, categories, or suppliers. Tasks cannot be used as "follow-up reminders for order X" or "review this product" — the feature is strictly a freeform to-do list for admin users. If a client needs task-to-entity linking it must be added via a migration in the client repo.


Configuration ​

  • Routes: application/config/routes.php:482-487.
  • Admin menu: application/config/admin_menu.php:378-398 — three entries ("All Tasks" auth/tasks :379, "My Tasks" auth/myTasks :386, "Tasks I Created" auth/tasksTo :393). See security gap #2 for why the per-entry role arrays are now mostly aligned but still not a security boundary.
  • Required roles: tasks() requires ADVISABLE or ADMIN role (Adv_auth.php:200-202). myTasks() and tasksTo() are open to any authenticated admin but self-scope to the session uid. Destructive mutations (taskDelete, taskCompleted, taskUncompleted) additionally require POST + taskCsrf token and pass an ownership check.
  • Session keys used by task endpoints: tskSearch (filter criteria), tskPageUrl (redirect target), taskCsrf (per-session mutation token, Adv_auth.php:599-608).

Client Extension Points ​

  • Override controller: extend Auth in application/modules/auth/controllers/Auth.php (main repo) or a client-repo equivalent. The empty subclass at application/modules/auth/controllers/Auth.php:1-7 exists specifically for this.
  • Override model: extend Tasks_model at application/modules/auth/models/Tasks_model.php:1-7.
  • Post-mutation hooks: override afterAddTask, afterEditTask, afterTaskDelete, afterTaskCompleted, afterTaskUncompleted from Adv_auth.php:678-701. A fork that previously relied on afterTaskDelete firing for completions and un-completions (the pre-#73 copy-paste bug) must now override the two new hooks, afterTaskCompleted and afterTaskUncompleted, to keep that behaviour — afterTaskDelete fires only on taskDelete going forward.
  • canModifyTask / verifyTaskRequest: both are protected methods on Adv_auth (Adv_auth.php:581-590 and :615-619). A client subclass may override them to apply stricter or different ownership rules.

Business Rules ​

  1. Three views, one template. All three list endpoints share tasks_list.php; only the forced where-clause and disabled filter dropdown differ.
  2. Binary lifecycle. Open (completed_at IS NULL) or done (completed_at IS NOT NULL). No priority, no in-progress state.
  3. Session-persisted filters. Search/filter criteria live in the session key tskSearch, shared across the three list views and the Vue bell endpoint.
  4. Session-persisted redirect. tskPageUrl remembers the last list URL so write endpoints return the admin to the correct paginated view.
  5. myTasks and tasksTo both require a valid session uid. Both methods (Adv_auth.php:232-236, :271-275) redirect with an error if the session uid is missing.
  6. Bell badge only refreshes on full navigation. The Vue widget fetches once on mounted() — there is no polling or push.
  7. Supervisor override. A user with role ADVISABLE or ADMIN may edit or delete any task, regardless of creator_id/assignee_id. Any other admin role is restricted to tasks they created or are assigned to (Adv_auth.php:581-590).
  8. Destructive actions require POST + token. taskDelete, taskCompleted, and taskUncompleted reject GET requests and requests with a missing or mismatched taskCsrf field (Adv_auth.php:615-619).

Tests ​

tests/Legacy/Auth/AdvAuthTaskSecurityTest.php covers the two new security predicates added in commit 0260023895 (#21/#22/#23). Tests use reflection on a constructor-less Adv_auth instance with injected stub session and input objects.

canModifyTask() cases (#22) ​

TestScenarioExpected
supervisor_can_modify_any_taskuid=99, role=ADMIN, task creator=1/assignee=2true
creator_can_modify_own_taskuid=5, non-supervisor role, creator_id=5true
assignee_can_modify_own_taskuid=7, non-supervisor role, assignee_id=7true
unrelated_admin_cannot_modify_taskuid=8, non-supervisor role, creator=1/assignee=2false

verifyTaskRequest() cases (#23) ​

TestScenarioExpected
verify_passes_for_post_with_matching_tokenPOST, session token = posted tokentrue
verify_fails_for_get_requestGET, token present but method wrongfalse
verify_fails_for_post_with_wrong_tokenPOST, token mismatchfalse
verify_fails_for_post_with_no_tokenPOST, no taskCsrf in bodyfalse

Coverage gaps ​

  • The tasks() RBAC gate (Adv_auth.php:200-202) is not unit-tested — it exercises allowRole() + error_401() interaction that requires a more integrated harness.
  • authorizeTaskMutation() orchestration (verifyTaskRequest → load task → canModifyTask) is not unit-tested as a whole; only its constituent predicates are covered.

  • AD-01 Admin Auth — admin authentication and the uid/role model that every task endpoint reads from.

No other flow is related — tasks have no connection to orders, customers, products, or any other business domain.