One section per module: what it's for, its routes, the controller/view/model/table it touches, and anything non-obvious about how it behaves. Route paths are relative to BASE_PATH (e.g. http://localhost:8080/).
All modules except Authentication require a logged-in session ($_SESSION['user']['user_id']); the constructor of every controller enforces this and redirects to / otherwise.
Controller: in.php (In) · Model: auth_model.php · Views: index/login.phtml · Tables: base_users, base_roles, base_user_sessions, log_audit
GET /— login page (redirects to/dashboardif already logged in)POST /in/login— validates credentials, loads the user's modules + permissions from RBAC tables into$_SESSION['user'], logs abase_user_sessionsrow, audits tolog_auditGET /in/logout— ends the session row, clears$_SESSION, redirects to/
Session cookie name is workflow_app (set in index.php). Passwords are bcrypt via PHP's password_hash/password_verify. See ARCHITECTURE.md §3 for the full flow and its limitations (module/permission cache staleness, no server-side permission enforcement).
Controller: dashboard.php · View: dashboard/index.phtml · Helper: application/helper/templates.php (Templates) · Tables: proj_sprints, proj_tasks, proj_adrs, base_config, base_roles, base_role_modules
GET /dashboard(also the default landing page after login)
Three always-visible parts, in order:
- Hero greeting — "Welcome back, {first_name}" + a static subtitle. Purely cosmetic, no data behind it.
- Quick actions — a horizontally-scrollable row of create-shortcuts (New Project, Log a Decision, Add Reference, New Note, Team Chat), reusing the same
.chip-row+initHScroll()mobile-scroll mechanism as filter chips/tabs elsewhere (seefooter.phtml). Uses a.dqa-*class prefix rather than the more obvious.qa-*, becauseassets/css/app.cssalready defines an unrelated.qa-row(a two-column Q&A layout used elsewhere in the app) — reusing that name silently broke the row's layout by inheriting itsdisplay:gridrule. - Stats — count of active sprints, the current user's open tasks (
assignee_id = you AND status != 'done'), proposed ADRs, a "priority tasks" panel (high/urgent priority, not done), a "due soon" panel (due within 3 days), and a merged/sorted recent-activity feed built by combining the 5 most recent tasks and 5 most recent ADRs client-side in PHP (usortbycreated_at). All queries are scoped to the logged-in user except the org-wide ADR/sprint counts.
An earlier version swapped the stats section for a full onboarding/welcome screen when the workspace was empty; that branch was removed in favor of always showing both parts — the empty states already read fine on their own ("No urgent or high priority tasks on your plate.", "No recent activity found.").
Workspace onboarding templates: the first time the Admin (role_id = 1) loads the Dashboard with base_config.workspace_template still empty, a full-screen overlay asks "what's this workspace for?" — four options (Software Development, Marketing, Video Production, General/Custom), each defined in Templates::all() as a role list, where every role has one of three access tiers (admin = every module; manager = every module except Settings; contributor = core work modules only, no Control Panel or Settings). Picking one calls POST /dashboard/api_apply_template, which TRUNCATEs and re-seeds base_roles/base_role_modules from that template (Admin is always re-inserted first, guaranteeing role_id = 1), then sets workspace_template so the prompt never shows again — this is a one-time, first-run choice, not a switchable setting; changing roles afterward means editing them by hand from Settings, same as before this feature existed.
The apply endpoint refuses to run if any base_users row other than the Admin already has a role assigned (role_id != 1), since replacing base_roles wholesale would otherwise silently orphan them — this shouldn't be reachable through the normal UI (the prompt only appears before anyone else has been invited) but is what actually keeps a direct API call safe. "Skip for now" just removes the overlay client-side without calling the endpoint, so it reappears on the next Dashboard load until a template is actually chosen.
The software template's role list and tier assignments are deliberately identical to the original hardcoded seed data in setup/workflow_database.sql — applying it changes nothing for an install that never touches this feature.
Controller: myqueue.php (Myqueue) · View: myqueue/index.phtml · Model: project_model.php (reused) · Table: proj_tasks
A personal, cross-project answer to "given everything assigned to me, what do I actually do first?" — priority (urgent/high/med/low) tells you how important a task is, not what order to work same-tier tasks in; this is that missing ordering. No RBAC gate beyond being logged in (granted to all 7 roles by default, like Notes) — every user manages only their own queue.
GET /myqueue— every task assigned to the current user that isn'tdone/cancelled, across all non-archived projects, ordered byproj_tasks.sequence_order(manually set via drag-and-drop) with tasks that haven't been ranked yet (sequence_order IS NULL) falling back to priority, then due date, then creation order (Project_model::getMyQueue()).POST /myqueue/api_reorder— accepts a comma-separatedtask_idslist in the intended order and bulk-writessequence_order = indexfor each (Project_model::reorderMyQueue()). TheWHERE ... AND assignee_id = <you>clause is the actual security boundary — even a tampered request can't re-rank someone else's tasks, since rows that don't match are simply not updated (verified: a task with a different assignee included in the same request is silently skipped, not errored).- The list itself is a plain HTML5 drag-and-drop reorder (
draggable/dragstart/dragover/drop, no library — matches the rest of the app's build-step-free convention) that persists ondragend. Each row also has a "done" checkbox that calls the existingProjects::api_update_task()rather than a new endpoint, then removes itself from the list.
Controller: projects.php (Projects) · Model: project_model.php (Project_model) · Views: projects/index.phtml, projects/task.phtml, project/detail.phtml, project/approve_preflight.phtml · Tables: proj_projects, proj_project_members, proj_features, proj_sprints, proj_tasks, proj_task_discussions, proj_task_references, proj_notifications, proj_task_templates, proj_task_template_items
The largest module. Hierarchy: Project → Features / Sprints → Tasks → Subtasks, plus task-level discussions and reference links.
GET /projects— list of projects the current user is a member of (proj_project_members), plus the team rosterGET /projects/task/{id}— single task detail page: subtasks, discussion thread, references, and the parent project's features/sprints (for reassignment dropdowns)GET /projects/api_get_project_details?project_id=— JSON: project + its features + sprints + tasks + members + all team members. This is the workhorse endpoint the project detail page (and, incidentally, Note Keeper's scope picker) both call to populate cascading dropdowns.GET /projects/api_get_task_detail?task_id=— JSON: task + subtasks + discussions + referencesPOST /projects/api_create_project,api_update_project— project CRUD; membership is (re)written viaProject_model::setProjectMembers()(delete-all-then-reinsert, not a diff). RequiresPROJECT_CREATE/PROJECT_EDIT.POST /projects/api_delete_project— despite the name, this archives rather than deletes (status = 'Archived', which list queries filter out); requires the Admin role and the caller's own password to confirm — this predates and is stricter thanPROJECT_DELETE, so it wasn't folded into that permission code.POST /projects/api_create_feature,api_create_sprint,api_create_task,api_create_subtask,api_import_csv— hierarchy CRUD + bulk CSV import. RequiresPROJECT_CREATE.POST /projects/api_update_task,api_add_dependency,api_remove_dependency,api_upload_reference,api_create_reference— task edits and anything attached to an existing task (dependencies, reference links). RequiresPROJECT_EDIT.POST /projects/api_post_discussion— adds a task comment; ungated by the CRUD permission system (treated like Team Chat, not like editing task data)GET /projects/api_download_template— static CSV template, no permission needed- Access is membership-scoped:
Project_model::getProjects()/getProject()both join throughproj_project_members, so only projects you've been added to show up. - New projects appear to go through a
PRE_FLIGHT/AWAITING_REVIEWapproval step before becomingACTIVE(seeproject/approve_preflight.phtmland thestatusfilter options inproject/index.phtml). proj_tasks.statusis an 8-value allow-list enforced inapi_update_task():todo,progress,review,on_hold,blocked,snoozed,cancelled,done. Moving toblocked/on_holdrequires a non-emptystatus_reason; moving tosnoozedrequiressnoozed_until(a resurface date). Both fields are cleared automatically when the task leaves that state.status_changed_atis stamped every timestatusactually changes (not on other field edits) — this is what powers the Control Panel's "stuck for N days" detection, sincecreated_atalone couldn't tell you when a task's current status began. Noteapi_import_csv()still validates against its own older, shorter allow-list (todo/in_progress/review/done) — an existing inconsistency, out of scope to reconcile here.proj_projects.owner_iddefaults tocreated_byat creation time but is independently editable (Edit Project Details modal → Owner dropdown, restricted to current project members). It's the escalation target for the Control Panel's "Escalate" action; if unset, escalation falls back to notifying all Admins.- Task dependencies (
proj_task_dependencies, phase 2 of the same feature): a task can be marked as depending on another task in the same project, via the task detail page's "Depends On" section.POST /projects/api_add_dependencyrejects self-dependencies and anything that would create a cycle (Project_model::wouldCreateDependencyCycle(), a BFS walk of the dependency graph);POST /projects/api_remove_dependencyremoves one by its ownid. This is deliberately a derived blocking signal, not a status override — a dependency-blocked task keeps whateverstatusit actually has (e.g. still showsTo Do), and the task detail page instead shows a separate "🔗 Blocked by TASK-N" line next to the status select, computed live fromProject_model::isBlockedByDependency()(any dependency whose ownstatus != 'done'). The Control Panel surfaces these tasks in its own "Blocked by Dependency" section (see below) independent of whatever their manually-set status is. - Task Templates (
proj_task_templates/proj_task_template_items): reusable, project-scoped task blueprints — an admin builds one once (e.g. "Episode Template": Write script / Edit footage / Design thumbnail), then "Apply Template" on any sprint bulk-creates real tasks from it, instead of re-typing the same task list every sprint. Managed from Projects → Settings ▾ → Task Templates (manageTemplatesModal()/templateBuilderModal()), applied either right after creating a new sprint or later from that sprint's group header in the task table.- Each template item is assigned one of three ways: a specific project member, a role, or left unassigned. Role-based items are not resolved when the template is built — resolution happens at apply-time (
Project_model::applyTaskTemplate()), by looking up which of the target sprint's project's current members hold that role. Exactly one match auto-assigns; zero or multiple matches leaves the generated task unassigned rather than guessing, and the apply response'sunresolvedcount drives a toast nudging the admin to assign those manually. This is what makes "assign by role" meaningful for a roster that changes between when a template is built and when it's next applied — a fixed member assignment wouldn't need this at all. POST /projects/api_create_template,api_update_template— requirePROJECT_CREATE/PROJECT_EDIT.api_delete_templaterequiresPROJECT_DELETEand only removes the template — tasks already generated from it are untouched.POST /projects/api_apply_template— requiresPROJECT_CREATE(it creates tasks). No dedup: applying the same template to the same sprint twice creates two full sets of tasks, same as re-importing the same CSV twice already does elsewhere in this module.
- Each template item is assigned one of three ways: a specific project member, a role, or left unassigned. Role-based items are not resolved when the template is built — resolution happens at apply-time (
Controller: controlpanel.php (Controlpanel) · View: controlpanel/index.phtml · Model: project_model.php (reused) · Tables: proj_tasks, proj_projects, proj_task_discussions, proj_notifications, base_config
A product owner's bird's-eye view across every project (not membership-scoped like the Projects module) — surfaces bottlenecks so they can be unblocked without manually digging through each project. Gated on the CONTROLPANEL module, granted by default to Admin, Tech Lead, Product Owner, and Project Manager (role_ids 1–4).
GET /controlpanel— four always-visible sections, each threshold-driven frombase_config(editable in Settings → Bottleneck Thresholds):- Blocked & On Hold — tasks in
blocked/on_holdlonger thanbottleneck_blocked_days(default 3), measured fromCOALESCE(status_changed_at, created_at). Shows the task'sstatus_reason. - Stale Backlog —
todotasks untouched longer thanbottleneck_stale_backlog_days(default 14). - Overloaded Members — anyone with more open tasks (
status NOT IN ('done','cancelled')) thanbottleneck_overload_task_count(default 8), grouped by assignee. - Snoozed & Due —
snoozedtasks whosesnoozed_untilhas arrived or passed. - Blocked by Dependency — tasks (not already
done/cancelled) that have at least oneproj_task_dependenciesentry pointing at a task that isn'tdoneyet. This is independent of the task's ownstatus— a task can beTo Doand still show up here, since dependency-blocking is a derived flag, never written intostatus(see MODULES.md → Projects & Tasks and DATABASE.md →proj_task_dependencies). Resolving the blocking task (marking itdone) removes the row from this section on next load, with no action needed on the dependent task itself. All five exclude archived projects.
- Blocked & On Hold — tasks in
POST /controlpanel/api_nudge— posts a comment on the task (proj_task_discussions) and notifies the assignee.POST /controlpanel/api_reassign— changesassignee_idand notifies the new assignee.POST /controlpanel/api_escalate— notifies the project'sowner_id; if unset, notifies every Admin (role_id = 1) instead.- Status changes from row actions reuse
Projects::api_update_task()rather than duplicating validation logic. GET /controlpanel/team_queue— an admin-side counterpart to My Queue: every active user's own ranked queue (Project_model::getMyQueue(), reused as-is), each independently drag-reorderable.POST /controlpanel/api_reorder_memberpersists it (Project_model::reorderMyQueue(), also reused — the sameWHERE assignee_id = <target>ownership clause that protects a regular user's own reorder call is what scopes an admin's reorder to the one member's tasks they dragged, nothing else). Both actions inherit the controller'sCONTROLPANELmodule gate, so this stays restricted to Admin/Tech Lead/Product Owner/Project Manager — verified a Developer-role session gets redirected on both the page and the API call. This exists because My Queue is deliberately self-service (only the assignee can rank their own tasks); Team Queue is the escape hatch for a manager who wants to set order on someone else's behalf.- When an admin reorders someone else's queue via Team Queue (not when a user reorders their own via My Queue),
api_reorder_memberfires two things so the change isn't silent: aproj_notificationsrow for the affected member ("{Admin name}changed the order of your task queue.", linking to/myqueue) and alog_auditentry (category = 'TASK_QUEUE',action = 'REORDER_MEMBER',entity_type = 'USER',entity_id= the affected user,metadata= the new task-id order) via the existingAuth::audit()helper — previously only called for login/logout, this is its first use elsewhere in the app.
Controller: knowledgehub.php (Knowledgehub) · View: knowledge/index.phtml · Table: kb_entries
GET /knowledgehub— visible entries only (see Visibility below), assembled into a tree viaparent_id(Knowledgehub::buildTree()), then rendered flat (nesting UI exists in CSS but the current dataset is flat — no folders have been created yet in the sample data)POST /knowledgehub/api_create_ref— add a reference (article/video/knowledge-doc/file) by URL. URLs are validated to start withhttp:///https://(blocksjavascript:injection — see../FIXES.md#8). RequiresKNOWLEDGE_HUB_CREATE.POST /knowledgehub/api_upload— upload a file instead of linking one; delegates toapplication/helper/upload.php, which allow-lists extensions and blocks executables (see../FIXES.md#6). RequiresKNOWLEDGE_HUB_CREATE.POST /knowledgehub/api_update_ref,POST /knowledgehub/api_delete_ref— edit/delete a reference (KNOWLEDGE_HUB_EDIT/KNOWLEDGE_HUB_DELETE). Didn't exist before this session — Knowledge Hub only had create until then.
Each entry has free-text tags (comma-separated, rendered as pills) that feed into Spotlight search.
Visibility: each entry has one of four levels (kb_entries.visibility), shown as a badge on the row and set via the same Level selector pattern as Note Keeper:
- Global 🌐 — everyone (the old, only behavior)
- Project 📁 — members of
project_idonly (proj_project_members) - Feature 🧩 — same audience as Project (there's no separate "feature membership" concept anywhere in this app — see ARCHITECTURE.md), just additionally tagged to a
feature_idfor filing/organization - Self 🔒 — only the creator (
created_by)
Enforced in Knowledgehub::index()'s WHERE clause, not just hidden in the UI: visibility = 'global' OR created_by = <you> OR (visibility IN ('project','feature') AND project_id IN <your project memberships>). Verified end-to-end with a second test account — a non-member sees only Global entries; adding them to the project reveals Project/Feature entries but never another user's Self entries.
Controller: adrs.php (Adrs) · View: adrs/index.phtml · Table: proj_adrs
GET /adrs— visible ADRs only (see Visibility below), with client-side status filter chips (All/Accepted/Proposed/Superseded)POST /adrs/api_create_adr— title, status, context, decision, consequences, plus visibility + optional project/feature.statusis validated server-side against an allow-list (Proposed/Accepted/Superseded) before insert — this used to accept any string and render it unescaped (stored XSS), fixed in../FIXES.md#7. RequiresADRS_CREATE.POST /adrs/api_update_adr,POST /adrs/api_delete_adr— edit/delete an ADR (ADRS_EDIT/ADRS_DELETE). Didn't exist before this session.
Each ADR is numbered ADR-0001, ADR-0002, ... by zero-padding its DB id, not a stored sequence.
Visibility: identical model and enforcement to Knowledge Hub's (see above) — proj_adrs.visibility is one of Global 🌐 / Project 📁 / Feature 🧩 / Self 🔒, shown as a badge next to the status badge. Previously ADRs had no visibility filtering at all — every logged-in user saw every ADR regardless of project membership, which was also true of Release Notes and (pre-this-change) Knowledge Hub. Release Notes has not been changed and still has no visibility filtering — worth doing if the same model is wanted there.
Controller: releases.php (Releases) · View: releases/index.phtml · Table: proj_release_notes
GET /releases— all release notes, newest first, with a?pid=query param to filter to one project client-sidePOST /releases/api_create_release— version label, date, project (optional), and a list of change-note lines stored as a JSON array in thenotesTEXT column (decoded back into a<ul>on render; falls back to treating the whole field as one line if it's not valid JSON, so old/malformed rows don't break the page). RequiresRELEASES_CREATE.POST /releases/api_update_release,POST /releases/api_delete_release— edit/delete a release note (RELEASES_EDIT/RELEASES_DELETE). Didn't exist before this session.
Controller: chat.php (Chat) · View: chat/index.phtml · Tables: chat_channels, chat_messages, proj_notifications
GET /chat— channel list + message viewGET /chat/api_get_initial_data— public channels + all other users (for DMs/mentions)GET /chat/api_get_messages?channel_id=&last_id=— polled every 3 seconds viasetInterval(see../ARCHITECTURE.md§5 — thewebsocket_server/directory exists but is not wired up; this is plain HTTP long-ish-polling). With nolast_id/before_id, returns the most recent 50 messages (oldest-first) plushas_more.GET /chat/api_get_messages?channel_id=&before_id=— lazy-loads the next-older page of up to 50 messages before the given id, for infinite-scroll-up in the message pane; also returnshas_morePOST /chat/api_send_message— acceptsmultipart/form-datawithmessage(optional if a file is attached) and an optionalfileupload (routed throughUploadHelper, same allow-list as Knowledge Hub). Images (jpg/jpeg/png/gif) render inline in the thread; other allowed types render as a download chip. Then scans the text for@FirstName,@LastName,@Full Name,@channel, or@alland fires aproj_notificationsrow (typementionor genericchat) to every other team member accordinglyPOST /chat/api_create_channel— new public channel
Emoji are handled client-side only (a small picker inserts unicode characters into the input) — no schema or backend involvement, since chat_messages.message and the DB connection are already utf8mb4.
There's no DM/private-channel implementation despite chat_channels.type supporting 'dm' in the schema — only type='public' channels are ever queried or created by the current code.
Encrypted at rest: message is AES-256-GCM ciphertext, same key/model as Note Keeper (see application/helper/crypto.php). Encrypted in api_send_message, decrypted in decryptMessages() before every api_get_messages response. @mention detection runs on the plaintext in the same request, before encryption — no decryption needed at write time. Chat notifications (proj_notifications) deliberately carry a generic body ("Sent a new message." / "You were mentioned in a message.") instead of a content preview — the notifications table isn't encrypted, so echoing message text into it would defeat the point. Team Chat is excluded from Spotlight search for the same reason as Note Keeper — LIKE can't match ciphertext.
Controller: notes.php (Notes) · View: notes/index.phtml · Table: user_notes
GET /notes— the current user's notes only (every query filtersuser_id = session user), with scope filter chips (All/Global/Project/Feature/Sprint/Task)POST /notes/api_create_note— title, body, tags, and a scope:global(no project/entity),project(project only), orfeature/sprint/task(project + a specific entity ID, chosen via a dropdown that's populated on the fly fromprojects/api_get_project_details)POST /notes/api_update_note,POST /notes/api_delete_note— both re-checkuser_id = session userin theWHEREclause, so there's no way to edit/delete another user's note even by guessing IDs
This is the one module in the app where records are private to their owner rather than team-shared — worth remembering if you extend it (e.g. don't add a "browse everyone's notes" view without deciding that's actually wanted).
Encrypted at rest: title and body are AES-256-GCM ciphertext (application/helper/crypto.php, key in config.php's encryption_key), encrypted in api_create_note/api_update_note, decrypted in index() before rendering. This is server-held-key encryption, not end-to-end — the app can always decrypt, so it protects against someone with only database access (a stolen backup, an over-broad DB credential), not a compromised app server. Losing the key makes every note permanently unreadable; see readme/ARCHITECTURE.md for the full model. Notes are deliberately excluded from Spotlight search (search.php) as a consequence — LIKE can't match ciphertext, and there's no separate search index.
Controller: updates.php (Updates) · View: updates/index.phtml · Tables: proj_tasks, proj_adrs, base_config
Each report tab is a contenteditable email draft with a formatting rail (structure/bold-italic-underline/color/highlight/link) — the generated content below is a starting draft, editable in place before sending.
GET /updates— auto-generated digest for both Daily and Weekly, each with:- A one-line narrative summary (
Updates::buildNarrative()) composed from the real counts below, not a static template — e.g. "2 tasks completed this week. 2 items need attention (overdue or unassigned). 3 tasks due this week." - Stat tiles: Daily = Completed Today / In Progress / Needs Attention. Weekly = Tasks Completed / New Tasks / ADRs Proposed / Needs Attention. The "Needs Attention" tile renders in amber when non-zero — the one number in the row that's meant to visually stand out from routine counts.
- A Needs Attention table: tasks that are overdue (
due_datein the past, not done) or unassigned withhigh/urgentpriority. This is deliberately the same query for both Daily and Weekly — overdue-ness doesn't reset with the report period. - A Due Soon table: tasks due today/tomorrow (daily) or in the next rolling 7 days (weekly — a
+7 dayswindow from today, not "Sunday of the calendar week," which would shrink to almost nothing by Saturday). - The original grouped Project → Sprint → Feature → Task table, plus an editable "Important Notes" block.
- "Tasks completed" is measured off
proj_tasks.completed_at(set automatically byProjects::api_update_task()/CSV import when status flips to/fromdone), notcreated_at— the original implementation measured "completed this week" as "created this week AND currently done," which undercounted anything with a normal multi-day lifecycle. SeeDATABASE.md. - "In progress" checks
status IN ('progress', 'in_progress')— both strings exist in the wild (seeDATABASE.md→proj_tasks), and only checking one silently dropped tasks depending on how they were created.
- A one-line narrative summary (
POST /updates/api_send_update— reads recipient email addresses frombase_config(daily_email_recipients/weekly_email_recipients, set in Settings) and simulates sending — no actual SMTP call is made yet (config.phphassmtp_hostetc. fields and Settings has a form for them, but nothing inupdates.phpusesPHPMailerormail()). Treat this as a stub to wire up, not a working mailer.
Controller: settings.php (Settings) · View: settings/index.phtml · Tables: base_roles, base_modules, base_role_modules, base_permissions, base_role_permissions, base_config, base_users
Gated on the SETTINGS module (only granted to ADMIN by default — see setup/workflow_database.sql).
GET /settings— workspace branding + email config form, the RBAC panel, and the team member list- RBAC panel — a role picker (not a single flattened matrix): select a role, see every module's View toggle plus, for the four CRUD-enabled modules (Projects & Tasks, Knowledge Hub, ADRs, Release Notes), Create/Edit/Delete checkboxes alongside it. Unchecking View force-unchecks and disables that module's CRUD checkboxes client-side — you can't grant Create on a module a role can't see.
POST /settings/api_save_role_access— saves one role's full access at a time: replaces that role'sbase_role_modulesrows (View) andbase_role_permissionsrows (Create/Edit/Delete) in a single call. Smaller blast radius than the old design (a mistake here only affects the one role being edited, not every role at once) and matches what's actually on screen. The Admin role's Settings module grant can't be removed through this endpoint — it's force-kept server-side even if omitted from the request, same protection the old UI gave it by rendering that one checkbox disabled. Remember role/permission changes don't affect already-logged-in sessions until they re-login (§3 in ARCHITECTURE.md).POST /settings/api_save_general— upserts arbitrary keys from a fixed allow-list intobase_config(workspace name/subtitle, email recipients, SMTP fields, bottleneck thresholds)POST /settings/api_add_user— admin-only (checked viarole_id == 1or role name containing "admin", not via the permissions table — see the RBAC note in ARCHITECTURE.md)POST /settings/api_change_password— requires the current password to verify before setting a new bcrypt hashPOST /settings/api_add_role— creates a new role;role_codeis auto-derived by uppercasing and slugifying the role name
Controller: notifications.php (Notifications) · Table: proj_notifications
GET /notifications/api_get_unread— the current user's unread notifications (bell icon in the header, polled — seefooter.phtml)POST /notifications/api_mark_read— mark one (id), several (ids, comma-separated), or all unread notifications as read for the current user
Populated by Project_model::addNotification(), currently called from Team Chat (@mentions) — nothing else in the app writes to this table yet, though the plumbing is generic enough for any module to use.
Controller: search.php (Search) · No dedicated view — the UI lives in application/views/static/footer.phtml (⌘K / Ctrl+K overlay, loaded on every page)
GET /search/api_global_search?q=— searches, in order: Projects, Tasks, Knowledge Hub, ADRs, your own Notes (explicitly scoped touser_id = session user— see../FIXES.md— this is a private-data search block sitting next to several team-shared ones, so it's the one place in this controller where getting theWHEREclause wrong would leak another user's private notes), People, Task Discussions, Team Chat messages.- Prefixing the query with
#switches every block that has atagscolumn to search tags instead of title/body text (Projects, Tasks, Knowledge Hub, ADRs, Notes). - Requires 2+ characters; returns
{status:0}-shaped early exits are avoided — unauthenticated requests get{status:0, error:'Unauthorized'}from the constructor instead of a redirect, since this is an AJAX-only controller.