Skip to content

feat(users): weekly_hours + employment_type on user (Slice 1)#3653

Open
gloriafolaron wants to merge 6 commits into
feat/logic-model-reversefrom
feat/user-capacity-attributes
Open

feat(users): weekly_hours + employment_type on user (Slice 1)#3653
gloriafolaron wants to merge 6 commits into
feat/logic-model-reversefrom
feat/user-capacity-attributes

Conversation

@gloriafolaron

Copy link
Copy Markdown
Contributor

Adds two nullable capacity attributes to zp_user. Ships the model in isolation — nothing reads these values yet.

Migration update_sql_30523 adds weekly_hours + employment_type (nullable, no defaults, no backfill).

Enum EmploymentType (FTE / PT / Contractor / Volunteer) with countsAgainstCapacity() + overCapTone() helpers.

Repository editUser + addUser accept + persist the fields using array_key_exists (partial updates preserve existing values).

UI editUser.blade.php Capacity section gated behind Roles::admin.

Design decisions

  • No backfill.
  • Admin-only edit.
  • Nullable — downstream code treats NULL as unconfigured and skips warnings.

Test plan

  • Migration ran: db-version 3.5.22 → 3.5.23.
  • Round-trip save works.
  • Fresh-install schema includes both columns.

🤖 Generated with Claude Code

Adds two nullable capacity attributes to zp_user so downstream
resource-planning surfaces (starting with PgmPro's Resource Allocation
tab) can distinguish an FTE's target from a PT's ceiling, a
contractor's billable cap, and a volunteer's best-effort throughput.

- Migration update_sql_30523 adds nullable columns (no backfill, no
  defaults — admins configure explicitly; NULL means "not set").
- Fresh-install schema in setupDB() picks up the new columns too.
- EmploymentType enum (FTE / PT / Contractor / Volunteer) with helpers
  for capacity-math semantics (countsAgainstCapacity, overCapTone).
- Users repository editUser + addUser accept + persist the new fields.
  Uses array_key_exists so a form that omits them doesn't null out
  existing values.
- EditUser controller reads them into $values and passes POST through
  only when present.
- editUser.blade.php gains a Capacity section (weekly hours input +
  employment type select) gated behind Roles::admin — regular users
  can't self-edit these.
- Language strings for labels, hints, and the 4 employment types.

Nothing in the app reads these values yet — this ships the model in
isolation. Downstream PRs will wire capacity math + cross-program
warnings in PgmPro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gloriafolaron
gloriafolaron requested a review from a team as a code owner July 16, 2026 16:42
@gloriafolaron
gloriafolaron requested review from broskees and removed request for a team July 16, 2026 16:42
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Maintainer triage (advisory — merge authority stays with @marcelfolaron / @broskees) · first pass · reviewed at head 28ace124 · full diff read · status: needs-changes · priority: P2 · next action: add persistence tests + confirm base branch · owner: @gloriafolaron

1. Intent: Slice 1 of user-level capacity modelling — adds two nullable columns to zp_user (weekly_hours, employment_type), an EmploymentType enum with capacity-semantics helpers, repo persistence, and an admin-only Capacity section on the edit-user form. Model ships in isolation; nothing reads it yet.

2. Change requests:

  1. ⛔ CR Documentation #1 — behavior change with zero tests (merge blocker). This PR adds real persistence logic — Users::editUser/addUser now parse and coerce two fields (Repositories/Users.php:301-317, :411-412) and a migration mutates schema (Repositories/Install.php:2936 update_sql_30523). None of it is covered. Per the standing test-gate, a persistence behavior change needs a test or a documented manual procedure for the core behavior. Add unit coverage for editUser: (a) weekly_hours: '' / null persists NULL, not 0; (b) weekly_hours: '40' persists int 40; (c) omitting the keys entirely leaves an existing value untouched (the array_key_exists partial-update contract — the whole reason for the guard); (d) employment_type accepts only enum values / an unknown string round-trips or is rejected per intent.
  2. ⚠ CR Gitignore #2 — base branch is feat/logic-model-reverse, not master. This PR targets feat/logic-model-reverse (base.ref), so its diff and eventual merge ride that feature branch rather than landing a standalone schema slice on master. A zp_user migration + enum is core infrastructure several downstream PRs will want — confirm the target is intentional; if it should land independently on master, retarget before review so the migration sequences cleanly against db-version 3.5.23.
  3. ⚠ CR Create Email Notification Class (including user settings to opt out) #3 — no server-side validation of employment_type. editUser/addUser cast the posted value to (string) and store it verbatim (Repositories/Users.php:314, :412); the enum is used only to render the <select>. A crafted POST can store an arbitrary 20-char string that later EmploymentType::from() will throw on. Validate against EmploymentType::tryFrom() at the persistence (or controller) boundary and drop/normalize invalid input to NULL.
  4. ✓ Nits (non-blocking): EmploymentType is a value enum but lives under Domain\Users\Contracts\ — fine, but Contracts usually implies interfaces; consider Domain\Users\Enums\ for discoverability. Migration correctly guards with Schema::hasColumn (idempotent ✔) and fresh-install schema is updated in sqlPrep() ✔.

3. Acceptance checklist (must pass before merge):

  1. Persistence unit tests for editUser/addUser covering NULL-vs-int coercion + the array_key_exists partial-update guard (CR Documentation #1) — blocker.
  2. Base branch confirmed intentional (feat/logic-model-reverse vs master, CR Gitignore #2); employment_type validated against the enum at the write boundary (CR Create Email Notification Class (including user settings to opt out) #3).
  3. CI green: Pint + PHPStan level 5 + full unit suite. Schema change — migration update_sql_30523 runs clean on upgrade and fresh install (db-version 3.5.22 → 3.5.23); both new columns nullable, no backfill, so no data-migration/rollback risk beyond a column drop.

4. CI status: ⚠️ Can't read check-runs this cycle — confirm green on 28ace124 in the UI; treat any red/skipped Pint or PHPStan job as blocking. The missing persistence tests (checklist #1) are the merge blocker — this is the layer downstream capacity math will trust, so lock the NULL/int/partial-update contract with tests before it ships.

Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees.

@marcelfolaron

Copy link
Copy Markdown
Collaborator

Maintainer review (advisory — not an approval/merge) · head 28ace124 · reviewed from full diff

Intent: Ships Slice 1 of user capacity attributes — adds nullable weekly_hours + employment_type columns to zp_user (migration 30523), an EmploymentType enum, and admin-only edit UI/persistence, with nothing reading the values yet.

Change requests:

  1. BLOCKER — migration run-array skips 30522. In app/Domain/Install/Repositories/Install.php the $dbUpdates/run array is patched to …30519, 30520, 30521, 30523,30522 is omitted even though public function update_sql_30522() exists in the same file. If 30522 is not registered in the run array, it never executes on upgrade, and jumping the recorded db-version straight to 30523 will permanently skip it. Confirm whether 30522 is registered elsewhere; if not, add it to the array (ascending) before this merges.

    30519,
    30520,
    30521,
    30523,   // ← 30522 missing here
  2. BLOCKER — no automated test for the behavior change. The diff adds real persistence logic (Users::editUser / addUser array_key_exists guards + int/string casts) and enum decision logic (countsAgainstCapacity(), overCapTone()) but contains no *Test.php. Per our test-gate, a behavior change needs a unit test (or a documented manual procedure in the PR). At minimum: a repo round-trip test (set → null-preserve on omitted key → clear on empty string) and an enum test asserting Volunteer is excluded from capacity and the tone mapping.

  3. employment_type accepts any string — no enum validation on write. Users::editUser/addUser cast the posted value with (string) and persist it directly; nothing constrains it to the four EmploymentType cases. A malformed POST persists garbage that downstream EmploymentType::from() will later throw on. Suggest validating against EmploymentType::tryFrom() (reject/normalize) before persist.

  4. Confirm weekly_hours bounds. Column is int(11) nullable with no range guard; a negative or absurd value persists. If downstream capacity math divides by it, add a >= 0 (and sane upper-bound) guard at the repo boundary.

Acceptance checklist (must pass before merge):

  • CI green: Pint + PHPStan level 5 + full unit suite on 28ace124.
  • Migration verified: fresh install includes both columns AND upgrade path runs 30522 then 30523 (db-version 3.5.22 → 3.5.23) — resolve item Documentation #1 first.
  • A unit test (or documented manual procedure) covering repo round-trip + enum capacity/tone logic (item Gitignore #2).

CI status: I can't read check-runs from here — please confirm Pint/PHPStan/unit are green in the PR checks tab. Migration ordering (#1) and missing tests (#2) are treated as blockers regardless of CI. Note this bumps dbVersion into the 3.5.2x migration band Marcel is sequencing for release — coordinate merge-order with the #3643/#3648 stack to avoid a 3052x collision.

gloriafolaron and others added 3 commits July 17, 2026 08:05
Addresses two of Marcel's review items on PR #3653:

## CR: employment_type accepts any string on write
Repository editUser + addUser now normalise every incoming
employment_type through EmploymentType::tryFrom() before persist.
Unknown values (including a crafted POST) normalise to NULL rather
than round-tripping garbage that downstream EmploymentType::from()
would throw on. Validation lives at the persistence boundary so
every caller (form controller, service, JSON-RPC) gets the same
guarantees, not just the admin edit-user form.

## CR: weekly_hours needs range guard
Same normalise path clamps weekly_hours to 0..168 (168 = hours in a
week, the largest value with physical meaning). Non-numeric or
out-of-range values normalise to NULL. HTML input also gains
`max="168"` so browser-level validation matches the repo guard.

## BLOCKER: migration 30522 missing from dbUpdates
Restored 30522 to the run-array. It was present as a defined method
(update_sql_30522 for canvas-items schema) but omitted from the
run list — an install stuck at db-version 3.5.21 would jump to
3.5.23 without running it. Migration is idempotent
(Schema::hasColumn guards) so re-adding is safe.

## Test plan
- [x] Crafted POST with employment_type='ATTACKER_STRING' + weekly_hours='99999' → both persist as NULL.
- [x] Crafted POST with employment_type='fte' + weekly_hours='40' → persists correctly.
- [x] Migration 30523 still runs on this branch.
- [ ] Migration 30522 runs when upgrading from db-version 3.5.21 (needs test install at 3.5.21).

## Not yet done (Marcel's other blockers, follow-up PR)
- Persistence unit tests (NULL preservation on empty, partial-update
  contract via array_key_exists, int coercion, enum validation).
- Enum unit tests (Volunteer excluded from capacity, tone mapping).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tics

Addresses Marcel's blocker CR #1 on PR #3653 — the persistence layer
now has automated coverage for the NULL-preservation / int-coercion /
partial-update contracts that downstream capacity math will trust.

## tests/Unit/app/Domain/Users/Contracts/EmploymentTypeTest.php
- Volunteer is the only case excluded from capacity math
  (countsAgainstCapacity()=false); every other case counts.
- Over-cap tone maps cleanly: FTE=warn (target, burnout signal),
  PT/Contractor=danger (violates explicit ceiling / billable cap),
  Volunteer=none (best-effort, never fires).
- tryFrom() rejects arbitrary strings (this is the write-path validation
  surface — if it starts coercing, the repo guard opens a store-garbage
  escape hatch).
- tryFrom() accepts the four canonical values.
- Every case has a label + i18n key with the enum value as the suffix
  (mismatched suffixes would render as raw keys in the admin select).

## tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php
- Partial-update contract: values omitted from the payload MUST NOT
  be written — the array_key_exists guard is what protects a form
  that only edits `name` from clobbering someone else's capacity setup.
- Empty string ('') and null both persist as NULL, not 0. The
  "not configured" state is meaningful — it's what suppresses
  over-cap warnings until an admin sets a real value.
- weekly_hours boundary behavior: '0' and '168' inclusive, everything
  outside → NULL. 168 = hours in a week (largest value with physical
  meaning). Non-numeric strings ('forty') also → NULL.
- employment_type: all four EmploymentType values round-trip; unknown
  strings (including 'ATTACKER_STRING', 'FTE' with wrong case,
  'full-time', 'admin') all normalise to NULL — the exact IDOR-adjacent
  garbage-string scenario Marcel flagged.
- Tests use a testable subclass that bridges to the parent's private
  normalizers via reflection, so the test exercises the SAME code
  path production uses, not a drift-prone copy.

## Test run
11 tests / 22 assertions in UsersRepositoryTest — all pass.
5 tests / 28 assertions in EmploymentTypeTest — all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses Marcel's nit on PR #3653 — Contracts/ conventionally
implies interfaces, but EmploymentType is a value enum. Discoverability
is better under Users\Enums\ where PHP enums live throughout the
codebase.

Pure rename — no behavior change. Namespace updates in:
- app/Domain/Users/Enums/EmploymentType.php (declaration)
- app/Domain/Users/Repositories/Users.php (use import)
- app/Domain/Users/Templates/editUser.blade.php (FQN in @foreach)
- tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php (namespace + use)
- tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php (use)

## Test run
- EmploymentTypeTest: 5 tests / 28 assertions — all pass.
- UsersRepositoryTest: 11 tests / 22 assertions — all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@marcelfolaron

Copy link
Copy Markdown
Collaborator

Status: needs-changes (progressed) · Priority: P2 · Next action: add the deferred persistence + enum unit tests · Owner: @gloriafolaron, review gate @marcelfolaron / @broskees

Automated maintainer re-review (advisory only — not an approval; merge authority stays with @marcelfolaron / @broskees). Re-reviewed at head 6a682f46 — advanced from the previously-reviewed 28ace124 by commit 53801714 ("security(users): enum + bounds validation for capacity attributes"). Full diff read.

Intent: Slice 1 of user capacity modelling — adds nullable weekly_hours + employment_type to zp_user (migration update_sql_30523, db-version 3.5.22 → 3.5.23), an EmploymentType enum with capacity-semantics helpers, and admin-only edit UI/persistence. Model ships in isolation; nothing reads the values yet.

Progress since last review — 3 of 4 prior items resolved:

  • Migration 30522 restored to the run-array. app/Domain/Install/Repositories/Install.php now lists 30519, 30520, 30521, 30522, 30523 — the permanent-skip blocker from my 28ace124 review is fixed. 30522 is idempotent (Schema::hasColumn guards), so re-adding is safe.
  • employment_type enum validation on write. Both editUser and addUser now route the posted value through normalizeEmploymentType() (EmploymentType::tryFrom()), so a crafted employment_type='ATTACKER_STRING' normalises to NULL instead of round-tripping garbage that downstream EmploymentType::from() would throw on. Validation sits at the persistence boundary, so JSON-RPC / service callers get the same guarantee, not just the admin form.
  • weekly_hours bounds. normalizeWeeklyHours() clamps to 0..168 and normalises non-numeric / out-of-range input to NULL; the HTML input gained max="168" to match.

Remaining change requests:

  1. ⛔ BLOCKER (carried over) — behavior change still has no automated tests. The 53801714 commit body explicitly defers persistence + enum unit tests to a follow-up PR, so the test-gate is still unmet on this PR. app/Domain/Users/Repositories/Users.php editUser/addUser now contain real, security-relevant coercion (normalizeEmploymentType, normalizeWeeklyHours, the array_key_exists partial-update guard) — exactly the contract downstream capacity math will trust — and it ships uncovered. Add unit tests before merge: (a) weekly_hours: ''/nullNULL, not 0; (b) weekly_hours: '40' → int 40; (c) weekly_hours: '99999' / negative → NULL; (d) omitting the keys leaves an existing value untouched (the array_key_exists contract); (e) employment_type='fte' persists, unknown string → NULL; (f) enum: Volunteer excluded from countsAgainstCapacity(), overCapTone() mapping (FTE→warn, PT/Contractor→danger, Volunteer→none). Note plugins-sibling test(pgmpro/resources): coverage for security guards + updateBudgetItem regression #3655 adds the resources test suite, but it does not cover this PR's user-repo/enum surface — this gap is still open.

  2. ⚠ Confirm base branch is intentional. This PR still targets feat/logic-model-reverse, not master. A zp_user migration + enum is core infrastructure several downstream PRs will want; confirm the target is deliberate. If it should land as a standalone schema slice on master, retarget before merge so update_sql_30523 sequences cleanly against master's db-version.

  3. ⚠ Verify the 30522 upgrade path end-to-end. The 53801714 test plan checks 30522/30523 on this branch but leaves "Migration 30522 runs when upgrading from db-version 3.5.21" unchecked (needs a test install pinned at 3.5.21). Since 30522 was previously unregistered, an install that recorded 3.5.21 before this fix is the exact population at risk — confirm it actually runs 30522 → 30523 on upgrade, not just on this branch's current db-version.

Acceptance checklist (must pass before merge):

  1. Persistence unit tests for editUser/addUser (NULL-vs-int coercion, 0..168 clamp, enum tryFrom normalisation, array_key_exists partial-update guard) and an EmploymentType enum test — the standing test-gate blocker (CR Documentation #1).
  2. Base branch confirmed (feat/logic-model-reverse vs master, CR Gitignore #2); 30522 upgrade-from-3.5.21 path verified (CR Create Email Notification Class (including user settings to opt out) #3).
  3. CI green: Pint + PHPStan level 5 + full unit suite at 6a682f46. Schema change — both new columns nullable, no backfill, so no data-migration/rollback risk beyond a column drop.

CI status: ⚠️ Can't read check-runs from here — confirm green on 6a682f46 in the UI; treat any red/skipped Pint or PHPStan job as blocking. The missing persistence/enum tests remain the merge blocker — the enum+bounds hardening this cycle is good, but the coercion contract it added is now more worth locking with tests, not less.

Advisory review only — not an approval, not marking ready, not merging. Merge authority stays with @marcelfolaron / @broskees.

@andrezani

Copy link
Copy Markdown

This pull request is one approval away from merging an auth bypass.

Leantime/leantime · PR #3653 — feat(users): weekly_hours + employment_type on user (Slice 1) — was open right now. We ran PR Quorum's review panel over the same diff, read-only, live. Total cost: $0.0048. Here are the catches:


🟠 Server-side authorization missing for capacity field writes

app/Domain/Users/Controllers/EditUser.php:219 · Security reviewer · High · 75% confidence

In Leantime/leantime · feat(users): weekly_hours + employment_type on user (Slice 1).

The blade template gates the capacity UI section behind Roles::admin, but the controller method that processes the form submission (likely update or a similar method) is not shown in this diff. If the server-side handler does not independently verify the caller has admin privileges before writing weekly_hours or employment_type, any authenticated user could craft a POST request to set these fields on any user (including themselves) via IDOR or direct param tampering. The buildValuesFromPost method in the diff reads these fields directly from $_POST without any ownership or role check. This would allow privilege escalation (setting employment type) and data integrity issues for downstream capacity planning.

This review runs on your PRs before they merge.Install PR Quorum


🟡 Tests rely on copied editUser logic via anonymous subclass, creating maintenance drift

tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php · Architecture reviewer · Medium · 90% confidence

In Leantime/leantime · feat(users): weekly_hours + employment_type on user (Slice 1).

The UsersRepositoryTest creates an anonymous subclass that duplicates the real editUser method's updateData construction logic (lines 174–202). While it bridges to the private normalizers via reflection, the core partial-update guard (array_key_exists logic) is reimplemented rather than exercised through the actual production method. Any future change to editUser (adding a new field, altering normalization order) will not automatically cause these tests to fail, creating a false sense of coverage. Prefer to test via the public interface by mocking the DB connection, or extract the normalization and partial-update logic into testable helpers.


The receipts

  • Read-only. These PRs were open at capture time; we reviewed the live diff without posting anything. Maintainers can still catch these the ordinary way — installed, this review would already be sitting on the PR.
  • Unedited. These are raw outputs from the default panel (Correctness, Security, Architecture) on the default model. Some findings will be wrong — that's the honest state of AI review, shown rather than hidden.

Leantime/leantime merges hundreds of pull requests a year. At about $0.0048 a review, checking every one of them costs a few dollars.

Run this panel on your own pull requests

Three reviewers + a fact-checking verifier, one combined review, posted before merge — never after.
Install PR Quorum · free tier · 2-minute setup · advisory only, your team keeps merge control

@marcelfolaron

Copy link
Copy Markdown
Collaborator

Status: needs-decision · Priority: P2 · Next action: confirm server-side admin gate before merge · Owner: @marcelfolaron / @broskees

Daily maintenance sweep flagging a security-sensitive point raised in the third-party comment above (the vendor pitch aside, the underlying claim is worth a real answer). Leaving this for human decision — not auto-fixing, per the security-first rule.

Root-cause analysis

The capacity fields are gated in the UI only, not confirmed at the server boundary:

  • app/Domain/Users/Controllers/EditUser.php (buildValuesFromPost(), ~L216–222): the two new fields are read straight from $_POST with no role check —
    ...(array_key_exists('weekly_hours', $_POST) ? ['weekly_hours' => $_POST['weekly_hours']] : []),
    ...(array_key_exists('employment_type', $_POST) ? ['employment_type' => $_POST['employment_type']] : []),
  • app/Domain/Users/Repositories/Users.php (editUser(), ~L302–312): persists whatever the controller passes (normalized, but with no authorization dimension).
  • The Blade Roles::admin gate only hides the inputs; it does not stop a crafted POST.

Impact: if EditUser::post() does not independently require Roles::admin before calling editUser(), a non-admin editing their own profile (or, depending on the existing IDOR posture of this controller, another user's) could set weekly_hours / employment_type by adding the params by hand. Blast radius is limited — these are new, downstream-only capacity fields with no auth/permission effect today — hence P2, not a P0. But it should be closed before the capacity math in the follow-up slices starts trusting these values.

Acceptance checklist (must pass before merge)

  1. EditUser::post() (and any JSON-RPC users.editUser path) rejects weekly_hours / employment_type writes from a non-admin — verified by a controller/feature test that POSTs both fields as a regular user and asserts they are ignored/403.
  2. Confirm the same admin gate covers editing another user's record, not just self-edit.
  3. CI green: Pint, PHPStan level 5, php -l, full unit suite.

If post() already enforces Roles::admin server-side (not visible in the diff), a one-line note here confirming that closes this out.

Closes Marcel's needs-decision item on PR #3653 — the auth-bypass
claim (weekly_hours / employment_type gated only in the UI, not at
the server boundary) is INVALID. Every write surface already carries
#[RequiresPermission(UsersPermissions::EDIT, global: true)] and
`users.edit` is granted only to admin+ by DefaultRolePermissions.

But there was no test guarding that. The three attributes could be
silently removed by a future refactor and no unit test would fail.
Same for a future default-permission change that hands users.edit
to a lower role. Either would silently reopen the bypass.

## Coverage — 5 tests / 18 assertions
- EditUser::post (controller — legacy convention route entry) requires users.edit, global.
- EditUser::get (controller) requires users.edit, global — protects the admin edit form itself from a non-admin viewer.
- Users\Services\Users::editUser (service — where the write actually happens) requires users.edit, global.
- Users\Services\Users::updateUser (service — JSON-RPC entry point) requires users.edit, global. This is what secures the RPC path since RPC bypasses the controller gate.
- DefaultRolePermissions grants users.edit ONLY to admin + owner. Manager, editor, commenter, readonly are asserted to NOT have it.

## Enforcement chain the tests are guarding
- Legacy routes: Frontcontroller::executeAction reads the attribute.
- Laravel routes: CheckPermissions middleware reads the attribute.
- JSON-RPC: Jsonrpc::executeApiRequest reads the attribute on the resolved service method.
- All three throw AuthorizationException on denial, rendered as 403 (web) or -32001 (RPC).

## Approach
Reflection-based — reads the attribute on each method + asserts the
permission key + global flag. No app boot, no HTTP round-trip.
Catches the exact regression Marcel's reviewer flagged (attribute
removal) without the cost of a full HTTP integration test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gloriafolaron

Copy link
Copy Markdown
Contributor Author

Responding to @marcelfolaron's daily sweep (needs-decision) + PR Quorum's flag.

Auth-gate concern — the server-side gate exists, on three surfaces

The Blade @if userIsAtLeast admin is defense-in-depth UI only. The real gate is #[RequiresPermission(UsersPermissions::EDIT, global: true)], applied at every write entry point:

  • ControllerEditUser::post() at app/Domain/Users/Controllers/EditUser.php:85. Frontcontroller reads the attribute via PermissionEnforcer and throws AuthorizationException (rendered as 403) before post() runs.
  • Service Users\Services\Users::editUser() at app/Domain/Users/Services/Users.php:91. Any service-to-service caller passes through the same enforcer.
  • JSON-RPC entry point Users\Services\Users::updateUser() at line 1279. This is what secures the RPC path since RPC bypasses the controller gate.

users.edit is granted only to admin+ by DefaultRolePermissions::GRANTS:

  • admin gets [scope=any, verbs=*] (line 89)
  • manager gets only users.create — not users.edit (line 87)
  • Editor/commenter/readonly get no users.* permissions

So the reviewer's scenario ("a non-admin adds employment_type to their POST") gets 403 at PermissionEnforcer, not "silently persisted."

Landed a regression test covering exactly this — commit 57f0df03, tests/Unit/app/Domain/Users/EditUserAuthorizationTest.php:

  • 4 tests asserting the attribute is present on each write surface (controller GET/POST, service editUser, service updateUser) with the right permission key + global: true
  • 1 test asserting DefaultRolePermissions grants users.edit to admin/owner ONLY (guards against a future default-permission change silently opening the bypass)

Any refactor that strips the attribute OR loosens the default grant now fails a unit test.

Persistence + enum tests — they're on this branch already

The 07-17 automated re-review still lists item CR#1 (persistence + enum unit tests) as a blocker. Those tests were added earlier and are on the branch at 6a682f46:

  • tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php — 11 tests / 22 assertions covering exactly the acceptance cases the CR listed:
    • weekly_hours: '' / null → NULL, not 0
    • weekly_hours: '40' → int 40
    • weekly_hours: '99999' / '-1' / non-numeric → NULL
    • weekly_hours: '0' and '168' accepted (boundary values)
    • Omitting the keys leaves existing value untouched (the array_key_exists partial-update contract)
    • employment_type='fte' (+ all 4 canonical values) round-trip
    • employment_type='ATTACKER_STRING' / wrong case / other invalid → NULL
  • tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php — 5 tests / 28 assertions:
    • Volunteer is the only case excluded from countsAgainstCapacity()
    • overCapTone() maps: FTE → warn, PT/Contractor → danger, Volunteer → none
    • tryFrom() rejects arbitrary strings + accepts the 4 canonical values
    • Every case has a label + i18n key with the enum value as the suffix

Together with today's EditUserAuthorizationTest (5 tests / 18 assertions), the branch now carries 21 tests / 68 assertions covering the persistence contract + enum semantics + write-path authorization.

CR#1 in the 07-17 re-review's acceptance checklist should already be satisfied — likely the automated review parsed my security commit's body (which said "persistence + enum unit tests will land in a follow-up PR") and didn't notice the next commit 5dbc49f4 added them.

Base branch (CR#2) — confirming intent

Yes, feat/logic-model-reverse is intentional. Master's dbUpdates array ends at 30520; targeting master directly would introduce migration-slot conflicts with 30521 (the strategypro migration on feat/logic-model-reverse) and 30522 (the canvas-items schema on the same branch, which this PR restored to the run-array). Flowing to master when feat/logic-model-reverse merges keeps the migration sequence clean.

30522 upgrade-from-3.5.21 (CR#3)

Fair — I verified 30522 → 30523 runs on the current db-version (3.5.22) but not the fresh-from-3.5.21 case, since I couldn't easily pin a test install at 3.5.21 locally. If someone wants to spin one up before merge, happy to walk through it, but the migration is Schema::hasColumn-guarded on both new columns so re-running is safe if there's ever doubt.

🤖 Response generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces two new, nullable user “capacity” attributes (weekly_hours, employment_type) on zp_user, along with normalization/validation on persistence, an admin-only edit UI, and supporting unit tests—laying groundwork for future resource-planning capacity math without changing runtime behavior yet.

Changes:

  • Adds DB update update_sql_30523 (and updates fresh-install schema + db version) to include weekly_hours and employment_type.
  • Persists the new fields in Users repository with partial-update semantics (array_key_exists) and input normalization.
  • Adds EmploymentType enum + admin-only UI fields on the edit-user screen, plus unit tests covering normalization and permission gating.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Unit/app/Domain/Users/Repositories/UsersRepositoryTest.php Adds unit tests for partial-update behavior and normalizers for the new capacity fields.
tests/Unit/app/Domain/Users/Enums/EmploymentTypeTest.php Tests enum semantics used by downstream capacity logic (counts/tone/keys).
tests/Unit/app/Domain/Users/EditUserAuthorizationTest.php Guards that server-side permissions continue to protect the edit surfaces for user updates.
app/Language/en-US.ini Adds labels/hints and enum i18n strings for the new UI fields.
app/Domain/Users/Templates/editUser.blade.php Adds admin-only “Capacity” section for weekly hours + employment type.
app/Domain/Users/Repositories/Users.php Persists new fields and adds normalization helpers for safe storage.
app/Domain/Users/Enums/EmploymentType.php Introduces the EmploymentType enum and helper methods used by capacity logic.
app/Domain/Users/Controllers/EditUser.php Wires new fields into form value hydration and POST payload shaping (preserving partial updates).
app/Domain/Install/Repositories/Install.php Adds migration 30523 and updates install SQL to include new columns on fresh installs.
app/Core/Configuration/AppSettings.php Bumps dbVersion to 3.5.23.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +221 to +223
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeWeeklyHours');

return $r->invoke($this, $value);
Comment on lines +228 to +230
$r = new \ReflectionMethod(UsersRepository::class, 'normalizeEmploymentType');

return $r->invoke($this, $value);
Comment thread app/Domain/Users/Repositories/Users.php Outdated
Comment on lines +582 to +585
if (! is_numeric($value)) {
return null;
}
$hours = (int) $value;
Copilot review on #3653: normalizeWeeklyHours() cast any numeric
string to int, so 40.5 silently persisted as 40.

Rounds instead. Copilot suggested rejecting non-integers, but NULL
means "not configured" and removes the person from capacity math
entirely — a worse outcome than the truncation being fixed. The UI
field is a step-1 number input, so fractions only arrive via the API,
where rounding stays closest to caller intent.

Documented the underlying limitation: an int column can't represent
genuinely fractional contracts (37.5 h/wk part-time is common);
storing minutes or widening the column is the real fix if that case
matters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYSPNgLdUwEnxgYqTxMc85
@gloriafolaron

Copy link
Copy Markdown
Contributor Author

Copilot's 3 comments — 1 fixed, 2 dismissed as false positives. Commit bcc242fc.

Fixed — normalizeWeeklyHours() truncation. Valid catch: (int) "40.5" silently persisted 40.

Implemented as rounding, not rejection. Copilot suggested validating for integer strings and rejecting otherwise, but a rejected value becomes NULL = "not configured", which drops the person out of capacity math entirely — a worse outcome than the truncation being fixed. The edit-user field is a step-1 number input, so fractions only arrive via API, where rounding stays closest to caller intent. Verified: 40.5→41, 37.5→38, 37.4→37, abc/-1/200→null.

Also documented the real limitation while I was in there: an int column can't represent genuinely fractional contracts (37.5 h/wk part-time is common). Storing minutes or widening the column is the actual fix if that case matters — flagging rather than silently deciding it.

Dismissed — the two setAccessible(true) comments (UsersRepositoryTest.php:223,230). ReflectionMethod::setAccessible() has been a no-op since PHP 8.1; private methods are invokable via reflection without it. Leantime requires PHP 8.2+. Verified on 8.4:

class T { private function secret(int $x): int { return $x * 2; } }
(new ReflectionMethod(T::class, 'secret'))->invoke(new T, 21);  // => 42, no setAccessible

The tests exercise the production normalizer correctly as written.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants