Skip to content

Admin Task Management

Flow ID: AD-55 | Module(s): auth | Complexity: Medium Last Updated: 2026-04-06

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:477-482:

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-221GET (POST for filter)All-tasks list. NOT filtered to the current user.
auth/myTasks[/{offset}]myTasks($offset = 0)Adv_auth.php:223-259GET (POST for filter)Tasks assigned to me (forces assignee_id = uid).
auth/tasksTo[/{offset}]tasksTo($offset = 0)Adv_auth.php:261-297GET (POST for filter)Tasks created by me (forces creator_id = uid).
auth/addTaskaddTask()Adv_auth.php:299-342GET form / POST saveFields: assigneeId, dueDate, title, description.
auth/editTask/{id}editTask($taskId)Adv_auth.php:344-389GET form / POST saveNo ownership check — any logged-in admin can edit any task.
auth/taskDelete/{id}taskDelete($taskId)Adv_auth.php:391-399GETPlain anchor link, no CSRF, no confirmation.
auth/taskCompleted/{id}taskCompleted($taskId)Adv_auth.php:401-409GETCalls afterTaskDelete hook (copy-paste bug).
auth/taskUncompleted/{id}taskUncompleted($taskId)Adv_auth.php:411-419GETSame copy-paste bug.
auth/resetTasksIndexresetTasksIndex()Adv_auth.php:421-426GETClears the tskSearch session key and redirects.
auth/getUserTasksgetUserTasks()Adv_auth.php:428-434GET (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()
  |
  +--> setTaskRedirectUrl('auth/<view>', $offset)         (Adv_auth.php:511-517)
  +--> setSearchTerms()                                    (Adv_auth.php:436-509)
  |     |
  |     +--> reads/writes session key tskSearch
  |
  +--> myTasks:  force $where['assignee_id'] = uid
  |   tasksTo:   force $where['creator_id']  = uid
  |   tasks:     no ownership filter
  |
  +--> Adv_tasks_model::getAll* ($where, $limit, $offset)
  +--> Adv_tasks_model::countAll*                          (used for pagination)
  +--> Render admin/auth/tasks_list

Creating a task (addTask, Adv_auth.php:299-342)

addTask()
  |
  +--> form_validation rules from addTaskValidation() (Adv_auth.php:527-535)
  |     |
  |     +--> 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:589-592 — empty hook)
  +--> redirect to tskPageUrl

Editing (editTask, Adv_auth.php:344-389)

Loads task by id, pre-fills form, on POST runs editTaskValidation (Adv_auth.php:544-552), saves via Adv_tasks_model::updateTask($taskId, $taskData), then invokes the empty afterEditTask hook. There is no ownership check — no comparison between the task's creator_id/assignee_id and the current session uid.

Complete / uncomplete / delete

All three are one-liners that call the model and immediately redirect to tskPageUrl. All three fire the same afterTaskDelete hook (see Known Issues #13):

MethodModel callHook fired
taskDelete (Adv_auth.php:391-399)deleteTask($taskId)afterTaskDelete
taskCompleted (Adv_auth.php:401-409)completeTask($taskId, now)afterTaskDelete (bug)
taskUncompleted (Adv_auth.php:411-419)unCompleteTask($taskId)afterTaskDelete (bug)

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)Loads full result set into PHP and returns num_rows() (see Known Issues #14).
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, :223, :261, :299, :344, :391, :401, :411, :421, :428).
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:477-482Route definitions.
Admin menuapplication/config/admin_menu.php:357-377Menu entries (see Security gap #2).
Controller baseapplication/core/Admin_c.phpecommercen/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_INCREMENTNoPrimary key.
creator_idINT(11)NoImplicit FK to users.id (no DB constraint).
assignee_idINT(11)NoImplicit FK to users.id. NOT NULL at schema level, but the controller accepts an empty POST and falls back to the session uid.
created_atDATETIMENoSet explicitly by the app on insert.
completed_atDATETIMEYesNULLNULL = open, any datetime = done.
due_dateDATETIMEYesNULLOptional deadline.
titleTEXTNoStored 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:436-509). 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:421-426), which unsets the session key and redirects to the last list URL (tracked via setTaskRedirectUrl, Adv_auth.php:511-517, 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, edit, delete) are plain <a> anchor tags hitting GET endpoints (tasks_list.php:182-186, 193-197, 202-206, 239-243, 247-251, 260-264). No confirmation dialog beyond the browser default — and none at all for delete.
  • Add form application/views/admin/auth/addTask.phpdueDate (datepicker), title, description (TinyMCE mceEditor), assigneeId (chosen-select populated via allowedUsers($users, $roles) from ecommercen/helpers/auth_helper.php:208-215).
  • Edit form application/views/admin/auth/updateTask.php — identical layout.

The description is echoed back into the list via content_shorten($task->description, 400) at tasks_list.php:179 and placed into a title attribute at :223. 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 => 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:75-81

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:85.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:428-434 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 has four interacting behavioral quirks that cause the badge count to be unreliable. All four are enumerated as bugs 9-12 in Known Issues & Security Gaps.

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. No per-endpoint RBAC. The only gate is the constructor "must be logged-in admin" check in Adv_admin_controller.php:27-72. Sibling methods on the same controller (index, add, edit, delete, undelete, changePassword) explicitly call allowRole([AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN], ...) at Adv_auth.php:28, 55, 86, 126, 140, 153, but none of the task methods were ever given the same treatment. Any admin role — including low-privilege custom roles — can list all users' tasks, assign tasks to anyone, edit any task, and delete any task.
  2. Menu-level gating is false security. application/config/admin_menu.php:357-377 shows auth/tasks only to [AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN], while auth/myTasks and auth/tasksTo are exposed to everyone (empty roles array). Because routes are plain auth/<method>, a non-admin role can still GET /auth/tasks directly — the menu hide is cosmetic.
  3. No ownership check on edit, delete, complete, or uncomplete. editTask (Adv_auth.php:344-389), taskDelete (:391-399), taskCompleted (:401-409), and taskUncompleted (:411-419) never compare the task's creator_id or assignee_id to the session uid. Any logged-in admin can mutate any other admin's task.
  4. Destructive actions are GET with no CSRF.
    • csrf_protection is globally false in application/config/config.php:131.
    • taskDelete, taskCompleted, and taskUncompleted are GET routes invoked via plain <a> tags in the list view (tasks_list.php:182-186, 193-197, 202-206).
    • A single <img src="/auth/taskDelete/42"> embedded in any page an admin loads is enough to destroy a task. A link shared in chat is enough to trigger it.
  5. Raw TinyMCE HTML in description. The controller saves post('description') untouched and the list view renders it via content_shorten($task->description, 400) (tasks_list.php:179) and into a title HTML attribute at :223. Stored XSS surface worth verifying.
  6. NOT NULL violations possible under edge cases. addTask writes assignee_id = (!empty(post('assigneeId'))) ? post('assigneeId') : session->userdata('uid') (Adv_auth.php:316-318). 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 link.
  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, and afterTaskDelete hooks at Adv_auth.php:584-597 are empty stubs explicitly designed for client repos to override — the upstream platform ships zero notification implementation.
  9. Bell: null due_date counts as overdue. The Vue getter filter is new Date(e.due_date).getTime() < Date.now() (assets/admin/js/tasks/tasks.js). In JavaScript, new Date(null).getTime() === 0, which is always less than "now", so every task with no due date at all is counted as "expired" by the bell.
  10. Bell: completed tasks still count. The getter never checks completed_at, so a completed task still contributes to the badge count until the next full page navigation drops it out of the top 20.
  11. Bell: hard cap of 20 silently clamps. The JSON endpoint getUserTasks calls getAllByAssignee(uid, where, 20, 0) (Adv_auth.php:428-434) — the bell can never show more than 20 even if the user has hundreds of overdue tasks.
  12. 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:436-509). If the admin just browsed myTasks with status filter set to "completed", the bell query inherits that filter and counts only completed tasks.
  13. Copy-paste hook bug: taskCompleted and taskUncompleted fire afterTaskDelete. Both taskCompleted (Adv_auth.php:401-409) and taskUncompleted (Adv_auth.php:411-419) call afterTaskDelete instead of dedicated afterTaskCompleted/afterTaskUncompleted hooks. There are no such hooks — they were never created.
  14. countAll uses num_rows() instead of COUNT(*). Adv_tasks_model::countAll() (Adv_tasks_model.php:98-104) loads the full result set into PHP and returns num_rows(). This is a performance anti-pattern that scales poorly with table size.

Form validation

addTaskValidation() (Adv_auth.php:527-535) and editTaskValidation() (Adv_auth.php:544-552) 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.


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:48-55Import summary (counts of inserted/failed products).
ecommercen/job/libraries/AdvUpdateProductsFromFileByBarcode.php:49Same pattern.
ecommercen/job/libraries/AdvUpdateProductsFromFileByProductCode.php:46Same pattern.
ecommercen/job/libraries/AdvUploadImagesFromZipFile.php:31-38Hardcoded Greek title/description at :28-29 — 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:477-482.
  • Admin menu: application/config/admin_menu.php:357-377 — three entries ("All Tasks", "My Tasks", "Tasks I Created"). See security gap #2 for why the per-entry role arrays are misleading.
  • Required roles: Effectively none at the endpoint level. Only the constructor "must be logged-in admin" check applies. This is a security gap, not a TODO.

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 from Adv_auth.php:584-597. Note the copy-paste bug — taskCompleted and taskUncompleted both fire afterTaskDelete, so a client override will run for completions as well as deletions unless the client also overrides the controller methods directly.

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:225-229, :263-267) 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.

  • 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.