Skip to content

Latest commit

 

History

History
233 lines (153 loc) · 31.5 KB

File metadata and controls

233 lines (153 loc) · 31.5 KB

Modules

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.


Authentication & Sessions

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 /dashboard if already logged in)
  • POST /in/login — validates credentials, loads the user's modules + permissions from RBAC tables into $_SESSION['user'], logs a base_user_sessions row, audits to log_audit
  • GET /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).


Dashboard

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:

  1. Hero greeting — "Welcome back, {first_name}" + a static subtitle. Purely cosmetic, no data behind it.
  2. 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 (see footer.phtml). Uses a .dqa-* class prefix rather than the more obvious .qa-*, because assets/css/app.css already 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 its display:grid rule.
  3. 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 (usort by created_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.


My Queue

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't done/cancelled, across all non-archived projects, ordered by proj_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-separated task_ids list in the intended order and bulk-writes sequence_order = index for each (Project_model::reorderMyQueue()). The WHERE ... 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 on dragend. Each row also has a "done" checkbox that calls the existing Projects::api_update_task() rather than a new endpoint, then removes itself from the list.

Projects & Tasks

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 roster
  • GET /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 + references
  • POST /projects/api_create_project, api_update_project — project CRUD; membership is (re)written via Project_model::setProjectMembers() (delete-all-then-reinsert, not a diff). Requires PROJECT_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 than PROJECT_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. Requires PROJECT_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). Requires PROJECT_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 through proj_project_members, so only projects you've been added to show up.
  • New projects appear to go through a PRE_FLIGHT / AWAITING_REVIEW approval step before becoming ACTIVE (see project/approve_preflight.phtml and the status filter options in project/index.phtml).
  • proj_tasks.status is an 8-value allow-list enforced in api_update_task(): todo, progress, review, on_hold, blocked, snoozed, cancelled, done. Moving to blocked/on_hold requires a non-empty status_reason; moving to snoozed requires snoozed_until (a resurface date). Both fields are cleared automatically when the task leaves that state. status_changed_at is stamped every time status actually changes (not on other field edits) — this is what powers the Control Panel's "stuck for N days" detection, since created_at alone couldn't tell you when a task's current status began. Note api_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_id defaults to created_by at 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_dependency rejects self-dependencies and anything that would create a cycle (Project_model::wouldCreateDependencyCycle(), a BFS walk of the dependency graph); POST /projects/api_remove_dependency removes one by its own id. This is deliberately a derived blocking signal, not a status override — a dependency-blocked task keeps whatever status it actually has (e.g. still shows To Do), and the task detail page instead shows a separate "🔗 Blocked by TASK-N" line next to the status select, computed live from Project_model::isBlockedByDependency() (any dependency whose own status != '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's unresolved count 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 — require PROJECT_CREATE/PROJECT_EDIT. api_delete_template requires PROJECT_DELETE and only removes the template — tasks already generated from it are untouched.
    • POST /projects/api_apply_template — requires PROJECT_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.

Control Panel

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 from base_config (editable in Settings → Bottleneck Thresholds):
    1. Blocked & On Hold — tasks in blocked/on_hold longer than bottleneck_blocked_days (default 3), measured from COALESCE(status_changed_at, created_at). Shows the task's status_reason.
    2. Stale Backlogtodo tasks untouched longer than bottleneck_stale_backlog_days (default 14).
    3. Overloaded Members — anyone with more open tasks (status NOT IN ('done','cancelled')) than bottleneck_overload_task_count (default 8), grouped by assignee.
    4. Snoozed & Duesnoozed tasks whose snoozed_until has arrived or passed.
    5. Blocked by Dependency — tasks (not already done/cancelled) that have at least one proj_task_dependencies entry pointing at a task that isn't done yet. This is independent of the task's own status — a task can be To Do and still show up here, since dependency-blocking is a derived flag, never written into status (see MODULES.md → Projects & Tasks and DATABASE.md → proj_task_dependencies). Resolving the blocking task (marking it done) removes the row from this section on next load, with no action needed on the dependent task itself. All five exclude archived projects.
  • POST /controlpanel/api_nudge — posts a comment on the task (proj_task_discussions) and notifies the assignee.
  • POST /controlpanel/api_reassign — changes assignee_id and notifies the new assignee.
  • POST /controlpanel/api_escalate — notifies the project's owner_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_member persists it (Project_model::reorderMyQueue(), also reused — the same WHERE 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's CONTROLPANEL module 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_member fires two things so the change isn't silent: a proj_notifications row for the affected member ("{Admin name} changed the order of your task queue.", linking to /myqueue) and a log_audit entry (category = 'TASK_QUEUE', action = 'REORDER_MEMBER', entity_type = 'USER', entity_id = the affected user, metadata = the new task-id order) via the existing Auth::audit() helper — previously only called for login/logout, this is its first use elsewhere in the app.

Knowledge Hub

Controller: knowledgehub.php (Knowledgehub) · View: knowledge/index.phtml · Table: kb_entries

  • GET /knowledgehub — visible entries only (see Visibility below), assembled into a tree via parent_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 with http:///https:// (blocks javascript: injection — see ../FIXES.md #8). Requires KNOWLEDGE_HUB_CREATE.
  • POST /knowledgehub/api_upload — upload a file instead of linking one; delegates to application/helper/upload.php, which allow-lists extensions and blocks executables (see ../FIXES.md #6). Requires KNOWLEDGE_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_id only (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_id for 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.


Any Decision Records (ADRs)

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. status is 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. Requires ADRS_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.


Release Notes

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-side
  • POST /releases/api_create_release — version label, date, project (optional), and a list of change-note lines stored as a JSON array in the notes TEXT 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). Requires RELEASES_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.

Team Chat

Controller: chat.php (Chat) · View: chat/index.phtml · Tables: chat_channels, chat_messages, proj_notifications

  • GET /chat — channel list + message view
  • GET /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 via setInterval (see ../ARCHITECTURE.md §5 — the websocket_server/ directory exists but is not wired up; this is plain HTTP long-ish-polling). With no last_id/before_id, returns the most recent 50 messages (oldest-first) plus has_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 returns has_more
  • POST /chat/api_send_message — accepts multipart/form-data with message (optional if a file is attached) and an optional file upload (routed through UploadHelper, 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 @all and fires a proj_notifications row (type mention or generic chat) to every other team member accordingly
  • POST /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.


Note Keeper

Controller: notes.php (Notes) · View: notes/index.phtml · Table: user_notes

  • GET /notes — the current user's notes only (every query filters user_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), or feature/sprint/task (project + a specific entity ID, chosen via a dropdown that's populated on the fly from projects/api_get_project_details)
  • POST /notes/api_update_note, POST /notes/api_delete_note — both re-check user_id = session user in the WHERE clause, 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.


Daily / Weekly Updates

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_date in the past, not done) or unassigned with high/urgent priority. 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 days window 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 by Projects::api_update_task()/CSV import when status flips to/from done), not created_at — the original implementation measured "completed this week" as "created this week AND currently done," which undercounted anything with a normal multi-day lifecycle. See DATABASE.md.
    • "In progress" checks status IN ('progress', 'in_progress') — both strings exist in the wild (see DATABASE.mdproj_tasks), and only checking one silently dropped tasks depending on how they were created.
  • POST /updates/api_send_update — reads recipient email addresses from base_config (daily_email_recipients/weekly_email_recipients, set in Settings) and simulates sending — no actual SMTP call is made yet (config.php has smtp_host etc. fields and Settings has a form for them, but nothing in updates.php uses PHPMailer or mail()). Treat this as a stub to wire up, not a working mailer.

Settings

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's base_role_modules rows (View) and base_role_permissions rows (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 into base_config (workspace name/subtitle, email recipients, SMTP fields, bottleneck thresholds)
  • POST /settings/api_add_user — admin-only (checked via role_id == 1 or 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 hash
  • POST /settings/api_add_role — creates a new role; role_code is auto-derived by uppercasing and slugifying the role name

Notifications

Controller: notifications.php (Notifications) · Table: proj_notifications

  • GET /notifications/api_get_unread — the current user's unread notifications (bell icon in the header, polled — see footer.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.


Global Search (Spotlight)

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 to user_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 the WHERE clause wrong would leak another user's private notes), People, Task Discussions, Team Chat messages.
  • Prefixing the query with # switches every block that has a tags column 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.