diff --git a/.gitignore b/.gitignore index 8268a84..439547d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # local scratch / generated artifacts /graphify-out/ PLAN_*.md +test-results/ .claude/ .opencode/ .pi/ diff --git a/AUTOBOOK.md b/AUTOBOOK.md index b792db3..ada3ffb 100644 --- a/AUTOBOOK.md +++ b/AUTOBOOK.md @@ -24,7 +24,7 @@ definitions see [GLOSSARY.md](GLOSSARY.md). - `dates`: a list of `{fromTS, toTS}` slots (absolute unix seconds). There may be several slots, possibly spanning several days. -- `login` (optional): book _as_ this user instead of yourself (see §3). +- `login` (optional): book _for_ this user instead of yourself (see §3). **Response** (HTTP 200) is a report with four buckets: @@ -43,41 +43,69 @@ overlapping slots in the request or a database conflict at commit time. ## 2. Permission gate (the `autoBook` endpoint) -Auto-book is **always a regular-user action**: the seat it picks is one the -**subject** of the booking could have picked themselves. Roles are equal here — -there is **no** site-admin super-user bypass and **no** confinement to "the zones -the actor administers". The actor's role only governs _who may book for whom_: - -- **Booking for yourself** (`login` omitted) → always allowed. Seat selection - acts as you. -- **Booking as another user** (`login` differs) → only a **site admin** or a - **zone admin of at least one zone on this plan** may do this. Once allowed, seat - selection acts entirely as the **target** user (see §3) — the actor's own zones - are irrelevant. A regular user may never book as someone else (`403`, code 104). - -The plan must also contain at least one zone the **subject** can book in; -otherwise the request is rejected (`403`, code 104). - -> This is intentionally more permissive than **manual** book-as, which also -> requires the actor to administer the _specific seat's_ zone. Auto-book-as only -> requires admin standing on the plan, because the seat it ultimately picks is — -> by construction — always one the target could have booked on their own. +- **Booking for yourself** (`login` omitted) → always allowed, and always a + regular-user action: the seat picked is one you could have picked yourself — + no site-admin super-user bypass, no confinement to zones you merely oversee. +- **Booking for another user** (`login` differs) → only a **site admin** or a + **zone admin of at least one zone on this plan** may do this. Unlike + self-booking, this is a book-for operation performed under the **actor's** + zone-admin authority: the seat pool (and the set of the target's bookings that + may be released to make room) is confined to the zones the **actor** + administers (unconfined for a site admin — see §3). Within that pool the + target only needs to be a **member** of the zone (any role, viewers + included) — not `zone_role ≤ user` — for the request to be accepted at all. + A regular user may never book for someone else (`403`, code 104). +- **This does not relax seat-level assignments.** The algorithm still only ever + picks a seat the target is eligible for by assignment (§3) — auto-book never + auto-places someone on a seat assigned to a different person, for either + self-book or book-for. That override is manual-book-for-only, where an admin + deliberately picks the exact seat with full knowledge of what it's assigned + to (PERMISSIONS.md §8) — auto-book's heuristic has no such context and must + not silently displace a third party's dedicated desk. + +The plan must also contain at least one eligible zone for the request (the +subject's own zones for self-booking; the actor's administered zones, +intersected with the target's membership, for book-for); otherwise the request +is rejected (`403`, code 104). + +> This mirrors **manual** book-for (PERMISSIONS.md §8) only on the zone-admin/ +> membership gate: both require the actor to administer the zone and only +> require target membership, not `zone_role ≤ user`. It does **not** mirror +> manual book-for's assignment override (see above and §3). --- ## 3. Whose seats, whose access -`runAutoBook(login, pid, dates)` selects seats that the **subject `login`** is -allowed to book — and **only** those: +`runAutoBook(login, pid, dates, allowedZids, releaseZids, is_book_for)` selects +seats from: -- enabled seats, in non-disabled zones, where the subject has `zone_role ≤ user` - (this includes zones they administer, since admin ≤ user numerically); **plus** +- enabled seats, in non-disabled zones, where the subject has a + `user_to_zone_roles` row for the zone — and, **unless `is_book_for`**, that + role must be `≤ user` (this includes zones they administer, since admin ≤ + user numerically); **plus** - all enabled seats in **public-book** zones (everyone may book those). -This is the subject's _regular_ access — exactly what they would get if they -clicked "find me a seat" themselves. For a book-as call the subject is the -**target** user, so the target can never be placed on a seat they could not have -booked on their own. The actor's roles never widen (or narrow) this pool. +For self-booking this is exactly the subject's regular access — what they'd +get clicking "find me a seat" themselves, scoped to the zones they can +actually book in (`is_book_for` is false, so the `≤ user` filter applies). + +For book-for, the **subject** is the target user, `is_book_for` is true, and +the seat pool is additionally filtered by `allowedZids` to the zones the +**actor** administers (`releaseZids` confines which of the target's existing +bookings may be displaced the same way — see §2). Within `allowedZids`, the +`≤ user` filter is dropped: the target merely needs a membership row, so a +viewer in one of the actor's administered zones is a valid auto-book target +there. The target is never placed in a zone the actor doesn't administer, and +never in a zone they aren't at least a member of. + +`is_book_for` only affects that zone-role gate. The seat-level assignment +classification below it (`seatInfo`: `none` / `direct` / `everyone` / +`blocked`) is computed identically for self-book and book-for — a seat +assigned to a login other than the subject, with no `everyone` row, is +`blocked` and excluded from the candidate pool either way. Only **manual** +book-for (`apply()`) skips the assignment check; the auto-book heuristic never +does. All of this is scoped to the **plan named in the request** (`pid`) — i.e. the plan currently open in the UI. Auto-book only ever considers the zones and seats diff --git a/FEATURES.md b/FEATURES.md index 72efbd8..8a4963f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -101,7 +101,7 @@ WARP has two independent role layers: **account-level** roles and **zone-level** | Role | Value | Description | |-------------|-------|-------------------------------------------------------------------------------------------------------------------------------------------| -| **Admin** | 10 | Full system access: user/group/zone management and reports. Zone-level actions (e.g., "Book As", enabling/disabling seats) still require a Zone Admin assignment in that zone — which admins can grant themselves. | +| **Admin** | 10 | Full system access: user/group/zone management and reports. Zone-level actions (e.g., "Book For", enabling/disabling seats) still require a Zone Admin assignment in that zone — which admins can grant themselves. | | **User** | 20 | Regular user. Can book seats in zones they are assigned to. | | **Blocked** | 90 | Cannot log in. Account exists but is disabled. | | **Group** | 100 | Virtual account representing a user group. Not a real person; cannot log in. | @@ -112,7 +112,7 @@ Each user (or group) can be assigned a role **per zone**: | Role | Value | What they can do in the zone | |----------------|-------|---------------------------------------------------------------------------------------------| -| **Zone Admin** | 10 | Assign/unassign users to the zone and to seats, enable/disable seats, book on behalf of any zone user ("Book As"), see disabled seats. | +| **Zone Admin** | 10 | Assign/unassign users to the zone and to seats, enable/disable seats, book on behalf of any zone member including viewers ("Book For"), see disabled seats. | | **User** | 20 | Book, update, and delete their own bookings. | | **Viewer** | 30 | See the zone map, seats, and other people's bookings, but **cannot book**. | @@ -332,10 +332,9 @@ Accessible via the user icon on the Zones management page. | Yellow (assigned) | Seat is assigned to others, you cannot book (visible only to non-admins) | — | | Blue | You already booked this seat exactly for the selected time | Remove | | Blue (rebook) | You booked this seat, but for a different time range — can update | Update | -| Blue (conflict) | You booked this seat, but another user has a conflicting booking — cannot update, only remove | Remove | -| Red (taken) | Booked by someone else or unavailable | — | +| Red (taken) | Booked by someone else, or your own booking overlaps theirs on this seat (same icon either way) | — (Release your own overlap; zone admins can also release the foreign booking) | | Gray (disabled) | Seat is disabled (visible only to zone admins) | Edit (toggle enabled) | -| Gray circle / gray person (view-only) | Seat is in a view-only or disabled zone you cannot book in (free vs. taken) | — | +| Gray circle / gray person (view-only) | Not available for you to book: no dates selected, disabled, or a free seat in a view-only zone | — | | No icon | No date/time selected | — | ### 7.3 Booking a Seat @@ -415,19 +414,20 @@ The UI modal surfaces this as sections titled "Booked", "Could not extend or reb The old "Already booked in another zone" section is gone; an existing booking on the same plan for the same slots takes top priority at step 1 instead. ### 8.4 Auto-Book for Zone Admins -- Zone admins can use the "Book As" feature with auto-book to find a seat for another user (see §9). -- When doing so via auto-book, the seat is chosen exactly as it would have been for the target user themselves (the actor's own zones do not restrict the choice). Manual "book as" is still scoped to the specific seat's zone adminship. +- Zone admins can use the "Book For" feature with auto-book to find a seat for another user (see §9). +- The seat pool is confined to the zones the **actor** administers on that plan (unconfined for a site admin). Within that pool, the target only needs to be a member of the zone — any role, including viewer — for the request to be accepted. Manual "book for" is likewise scoped to the specific seat's zone adminship, with the same membership-only requirement on the target. +- Unlike manual "book for", auto-book does **not** override seat-level assignments: a seat assigned to a different person is never auto-picked for the target, whether self-booking or booking for someone else. Only a manually-picked seat (an admin deliberately choosing that exact seat) can override an assignment. --- -## 9. "Book As" (Zone Admin Feature) +## 9. "Book For" (Zone Admin Feature) -- A "Book As" input field appears in the plan-view side panel (the booking map) for zone admins. -- It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users. -- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. +- A "Book For" input field appears in the plan-view side panel (the booking map) for zone admins. +- It is an autocomplete field listing all users with access to the zones on that plan (resolved through the `user_to_zone_roles` view — the single source of truth). For a public zone that is every non-group user, including blocked users (an admin can manage and book on behalf of blocked users); for an enabled zone it is the explicitly assigned users, including viewers. +- Selecting a user switches the entire plan view to show what that user sees, including their bookings and conflicting bookings across the plan. Seats in zones the admin administers show real booking actions even if the target is only a viewer there — booking-for overrides the viewer restriction, any seat-level assignment, and a seat-level disable for that seat. A seat assigned to someone else renders plain green "available" under book-for; a seat assigned to the target beyond their days-in-advance window renders blue "availableAssigned"; a seat the admin has disabled keeps its disabled icon (grey X) as a visual cue but is bookable under book-for — click offers Book, or Update when the target already has a conflicting booking in the zone group. This override is book-for-only: an admin booking **for themselves** sees a disabled seat as not bookable and must re-enable it first (self-booking is the regular user flow — the picker's own-login entry is the exit from book-for). The zone-type DISABLED block still rejects book-for outright — override the seat, not the zone. - When the admin books, updates, or removes a booking, it is performed **on behalf of the selected user**. - The admin can also auto-book for the selected user. -- Clearing the "Book As" field (pressing Enter while empty) reverts to the admin's own view. +- Clearing the "Book For" field (pressing Enter while empty) reverts to the admin's own view. --- @@ -465,9 +465,9 @@ The old "Already booked in another zone" section is gone; an existing booking on ## 11. Bookings List & Report ### 11.1 Bookings List (available to all users) -- Shows **future** bookings in zones the user is assigned to. +- Shows **future** bookings in zones the user is assigned to, plus the user's own future bookings in zones they no longer have access to (so they can still release them — the plan map can't reach a seat in a zone they can't open). - Columns: User name, Plan, Seat, Time (merged from/to into one column). -- A **delete button** (🗑) appears for bookings the user can remove (own bookings where they have User role, or any booking where they have Zone Admin role). +- A **delete button** (🗑) appears for any of the user's **own** bookings (regardless of their role in that zone — viewers and users who have lost access can still release their own), plus any booking in a zone where they have **Zone Admin** role. - Filtering by user name, plan, seat, and date range. - Sorting by time and user name. - Paginated with remote data loading. @@ -795,19 +795,23 @@ menus) switches between them, showing a **moon** icon in light mode and a **sun* | Change own password | ✅³ | ✅³ | ✅³ | ✅³ | ¹ System Admins do not automatically hold zone-level rights. Zone-level actions require the corresponding zone role (User or Zone Admin) in that zone — which a System Admin can always grant themselves via zone user assignment. -² Viewers can remove their own bookings (even from zones they are only a viewer in), to clean up leftover bookings after role changes. +² Viewers — and users who have lost access to a zone entirely — can remove their own bookings there, to clean up leftover bookings after role changes. ³ Only available with built-in authentication (not SSO). --- ## 27. Plan View Interaction Summary -| Seat State | No Dates | Green (Book) | Green (Rebook) | Blue (Update) | Blue (Conflict) | Blue (Exact) | Red (Taken) | Yellow (Assigned) | Gray (Disabled) | +| Seat State | No Dates | Green (Book) | Green (Rebook) | Blue (Update) | Red (Taken, own overlap) | Blue (Exact) | Red (Taken, foreign) | Yellow (Assigned) | Gray (Disabled) | |-----------------------|----------|-----------------------|----------------------|---------------|-----------------|--------------|-------------|-----------------------------|-----------------------------| | **User actions** | — | Book | Book (replaces) | Update | Remove | Remove | — | — | — | -| **Zone Admin actions**| — | + Book As, + Edit | same | same | same | same | + Edit | + Edit | + Edit | +| **Zone Admin actions**| — | + Book For, + Edit | same | same | same | same | + Release, + Edit | + Edit | + Edit | | **Viewer actions** | — | — | — | — | — | — | — | — | — | +A viewer (view-only access to every zone on the plan) can still **release their own booking** from the plan map, and **shorten** it (Update) when the selected time is fully contained within the booking — a pure shrink is always allowed, even in a view-only or disabled zone. Clicking a non-exact own booking opens the action panel with Release, plus Update when the selection is a pure shrink; non-actionable seats (free, taken by others, assigned) open no panel. The auto-book button and book-for input stay hidden in viewer mode. + +A zone admin can **release another user's booking** from the plan map by clicking a taken seat in a zone they administer — the action panel offers Release (apply() allows it via the per-seat zone-admin check for foreign removes). Non-admins get no action on a taken seat. + --- ## 28. Configuration Reference diff --git a/GLOSSARY.md b/GLOSSARY.md index 652c4ea..9226222 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -171,21 +171,24 @@ The range of dates that are open for booking at all, configured installation- wide (a number of weeks in advance). No one — not even an admin booking for themselves — can book outside this window through the normal booking screen. -### Book as +### Book for A convenience for zone (and site) admins: book (or auto-book) a seat **on behalf -of another user**. The booking is recorded under the target user, who must be -allowed to book that seat. (Manual book-as requires the admin to manage the -seat's own zone; auto-book-as only requires admin standing on the plan — see -[PERMISSIONS.md](PERMISSIONS.md) §8.) +of another user**, exercising the admin's own zone-admin authority rather than +the target's. The booking is recorded under the target user, who only needs to +be a **member** of the zone (any role, viewers included) — the admin's standing +overrides seat-level restrictions (assignments and disabled seats) there. Both manual and +auto-book-for require the admin to administer the zone in question — see +[PERMISSIONS.md](PERMISSIONS.md) §8. ### Auto-book ("find me a seat") Instead of picking a specific seat, the user asks the system to choose the best available seat on the plan for the selected dates, honouring assignments, the -release window, and existing bookings. Admins can also auto-book _as_ another -user, in which case it runs **as that user** — picking only seats the target -could book themselves. See [AUTOBOOK.md](AUTOBOOK.md) for the selection priority. +release window, and existing bookings. Admins can also auto-book _for_ another +user, in which case the seat pool is confined to the zones **the admin** +administers — picking only seats within that pool the target is at least a +member of. See [AUTOBOOK.md](AUTOBOOK.md) for the selection priority. ### Effective zone role diff --git a/PERMISSIONS.md b/PERMISSIONS.md index dd53d17..025d3f6 100644 --- a/PERMISSIONS.md +++ b/PERMISSIONS.md @@ -190,7 +190,7 @@ A plan can mix zones, so the screen picks a mode from the user's roles across al zones on that plan: - **Admin mode** — the user administers _at least one_ zone on the plan (or is a - site admin). The assign / enable / disable / book-as tools appear. + site admin). The assign / enable / disable / book-for tools appear. - **Viewer mode** — the user can only _view_ every zone they can reach on the plan (no booking anywhere). The booking actions and the auto-book button are hidden. @@ -228,41 +228,79 @@ Rule 6 is enforced at the database level, so it holds no matter which path --- -## 8. Booking on behalf of others ("book as") +## 8. Booking on behalf of others ("book for") -### Manual book-as (picking a specific seat for someone) +Book-for is fundamentally different from booking as yourself: the actor is +acting in their capacity as **zone admin**, not as the target. Self-booking +requires the booker's own effective role to be _user_ or better; book-for +instead requires the **actor** to administer the zone, and only requires the +**target** to be a _member_ of it — any zone role, viewers included. When the +actor **manually** picks the seat, they may also override seat-level +assignment restrictions there (they could reassign the seat themselves +anyway) — but only in zones they administer, and only for that deliberate, +explicit pick; the auto-book heuristic never does this (see below). Disabled +zones reject booking outright, for everyone, book-for included. + +### Manual book-for (picking a specific seat for someone) - **The actor must be a zone admin of the seat's zone** (site admins qualify everywhere). Being an admin of a _different_ zone on the same plan is not - enough — e.g. an admin of the enabled zone cannot book-as into a public-book - zone on the same plan unless they also administer that public-book zone. -- **The target must be allowed to book in that zone** — i.e. the _target's_ - effective role must be _user_ or better. You cannot park a booking on someone - who has no business in the zone. (In a public-book zone that is everyone; in a - public-view zone only users with an explicit grant; in an enabled zone only - those granted _user_/_admin_.) + enough — e.g. an admin of the enabled zone cannot book for someone into a + public-book zone on the same plan unless they also administer that + public-book zone. +- **The target only needs to be a member of that zone** — a + `user_to_zone_roles` row for the zone, of _any_ role. A zone admin can book + for a viewer in their own zone; this is the point of the mechanism (it makes + view-only zones genuinely centrally managed — the office manager books, + viewers just see the outcome). +- **Assignments do not bind the target under book-for.** If the seat is + assigned to someone else, or its days-in-advance window would otherwise + block the date, the zone admin's book-for override still succeeds. +- **Release confinement still applies to unrelated zones.** Book-for can only + release (delete) an existing conflicting booking of the target's if that + booking is in a zone the actor also administers. If the conflicting booking + is in a zone outside the actor's control, the operation is rejected rather + than silently deleting it. - **Disabled zones** still reject the booking outright. ### Auto-book ("find me a seat") -Auto-book is always a **regular-user action**: it picks a seat the **subject** -(the booking's owner) could have picked themselves — no super-user bypass, and no -confinement to the actor's own zones. The actor's role only gates _who may book -for whom_: - -- **For yourself** — always allowed; the pool is the zones where _you_ have a - regular booking grant (or that are public-bookable). The site-admin bypass is - excluded here (§4), so auto-book never picks a zone you only oversee. -- **As another user** — only a **site admin** or a **zone admin of some zone on - the plan** may trigger it. The seat is then chosen exactly as the **target** - would get it (across the target's own accessible zones), so the target is never - placed on a seat they couldn't book themselves, and the actor's zones do not - constrain the choice. This is intentionally looser than manual book-as (which - also requires the actor to administer the specific seat's zone) — safe, because - the picked seat is by construction one the target could book on their own. +- **For yourself** — always a regular-user action: it picks a seat you could + have picked yourself — no super-user bypass, and no confinement to zones you + merely oversee. The pool is the zones where _you_ have a regular booking + grant (or that are public-bookable). The site-admin bypass is excluded here + (§4), so auto-book never picks a zone you only oversee. +- **For another user** — only a **site admin** or a **zone admin of some zone + on the plan** may trigger it. The seat pool (and the release side — which + bookings of the target's may be displaced) is confined to the zones the + **actor** administers (unconfined for a site admin). Within that pool the + target only needs to be a member — any role, viewers included — for the + request to be accepted at all; this mirrors the manual book-for rule only on + that zone-admin/membership gate. **It does not mirror the assignment + override**: the heuristic still excludes seats assigned to someone other + than the target, exactly as it would for a self-book — auto-book never + silently displaces a third party's dedicated desk, even under book-for. See [AUTOBOOK.md](AUTOBOOK.md) for the full auto-book selection priority. +### Acting for yourself is normal mode + +A zone admin who selects **their own login** in the book-for picker is, for +that selection, a regular user: no `book.login` is sent on the wire, so the +backend never enters the book-for path, and the plan renders the admin's own +normal-mode icons (assignments and days-in-advance windows apply; no green +override). The picker keeps the admin's own login in the list precisely as +the **exit** from book-for mode — book-for is "acting for *another* user"; +acting for yourself is just booking. (The backend tolerates an explicit +`login = ` in `apply()` — harmless, but unreachable from the UI.) + +Notably, this means an admin **cannot book themselves onto a seat they have +disabled**: under self-view the disabled seat stays grey (no Book action +offered — the admin must re-enable it first). Booking **for another member** +onto that same disabled seat *is* offered (book-for overrides the seat-level +disable; see §11 invariant #4). The asymmetry is intentional — for themselves, +an admin is a regular user, and a disabled seat is disabled for regular users. + --- ## 9. Decision flow: "can user **U** book seat **S**?" @@ -307,8 +345,10 @@ See [AUTOBOOK.md](AUTOBOOK.md) for the full auto-book selection priority. ✅ BOOKED ``` -(For _book as_, run the booking-access checks against the **target** user and -additionally require the **actor** to administer S's zone — §8.) +(For _book for_, replace the first two checks: require the **actor** to +administer S's zone, and the **target** to merely be a member of it — any +role — instead of running the role/assignment checks against the target. The +zone-disabled, booking-window, and conflict checks still apply — §8.) --- @@ -317,23 +357,58 @@ additionally require the **actor** to administer S's zone — §8.) A plan **"Office"** has three zones: - **Open** — public (book) -- **Quiet** — enabled (private); _Bob_ is zone admin, _Carol_ is zone user +- **Quiet** — enabled (private); _Bob_ is zone admin, _Carol_ is zone user, _Eve_ is zone viewer - **Lab** — disabled; _Bob_ is zone admin | Person | Open | Quiet | Lab | Notes | | ------------------------- | ----------- | ----------- | ----------- | -------------------------------------------- | | **Dana** (no grants) | book | – | – | public-book gives everyone _user_ in Open | | **Carol** (user in Quiet) | book | book | – | sees Lab? no — disabled & no admin grant | +| **Eve** (viewer in Quiet) | book | view only | – | can see Quiet's occupancy, cannot book there | | **Bob** (admin Quiet+Lab) | book | book+manage | view+manage | cannot _book_ in Lab (disabled), only manage | | **Site admin** | book+manage | book+manage | view+manage | super-user; still cannot book in Lab | -Book-as on this plan: +Book-for on this plan: + +- Bob books **for Carol** in **Quiet** → ✅ (Bob admins Quiet, Carol is a member). +- Bob books **for Eve** in **Quiet** → ✅ (Bob admins Quiet, Eve is a member — + her _viewer_ role doesn't matter for book-for; if the seat happens to be + assigned to someone else, Bob's book-for still overrides that). +- Bob books **for Dana** in **Open** → ❌ Bob is only a _user_ of Open, not its + admin; he cannot book for anyone there (even though Dana herself could book). +- Bob auto-books **for Eve** → he may trigger it (he admins Quiet, a zone on + the plan), and the seat pool is confined to **zones Bob administers** + (Quiet, Lab) — never Open, even though Eve could book there herself. Lab is + excluded too (disabled). So the seat is picked from Quiet only. +- Site admin books **for Dana** in **Open** → ✅ (super-user admins Open; Dana + is a member there). + +--- -- Bob books **as Carol** in **Quiet** → ✅ (Bob admins Quiet, Carol may book there). -- Bob books **as Dana** in **Open** → ❌ Bob is only a _user_ of Open, not its - admin; he cannot book-as there (even though Dana herself could book). -- Bob auto-books **as Carol** → he may trigger it (he admins Quiet, a zone on the - plan), and it then runs **as Carol**, so it may place her in **any zone Carol - can book** (Quiet or the public Open) by the auto-book priority — never in Lab. -- Site admin books **as Dana** in **Open** → ✅ (super-user admins Open; Dana may - book there). +## 11. Seat-icon & action invariants + +The plan-map display and the click-to-act modal follow four invariants. Any +behaviour not derivable from them is a bug — or requires amending them first. + +1. **Icons state facts; colours carry the meaning.** Green = the actor (or + their book-for target) can book here; blue = the actor's/target's own + (booking or assignment); grey = not bookable/updatable — except the + disabled-seat cue under book-for, which stays grey while actionable. + Arrows = acting here changes existing bookings. +2. **The click reveals the permitted actions; the icon never enumerates them.** + A grey seat may still offer *Release* — an own overlap, or a foreign + booking for a zone admin. The icon is a scan-friendly summary, not an + action list. +3. **Any operation that strictly shrinks the actor's own bookings is always + allowed, everywhere.** Release is the shrink-to-nothing case (already + ungated); shortening a booking to a contained sub-range is the same + invariant generalised — it bypasses the role, zone-disabled, + seat-disabled, assignment, and booking-horizon checks. Book-for is never a + shrink. +4. **A zone admin is unrestricted for seat-level matters inside their zone** + (book-for members; override assignments, days-in-advance windows, and + disabled seats; release anyone) **and powerless across its boundary** + (release confinement — a conflicting booking in a zone the actor does not + administer is not silently deleted; the zone-type DISABLED block still + rejects book-for outright). Acting for themselves, an admin is a regular + user (see §8, "Acting for yourself is normal mode"). diff --git a/e2e/helpers/booking.ts b/e2e/helpers/booking.ts index dd754ba..bdff562 100644 --- a/e2e/helpers/booking.ts +++ b/e2e/helpers/booking.ts @@ -229,6 +229,29 @@ export async function clickActionBtn( await page.waitForTimeout(200); } +/** Activate book-for for the given display label (e.g. "Bar [user2]"). */ +export async function activateBookFor(page: Page, label: string): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.pressSequentially(label.split(' ')[0], { delay: 50 }); + const item = page.locator('ul.autocomplete-content li', { hasText: label }); + await expect(item).toBeVisible({ timeout: 5000 }); + await item.click(); + // book-for fires a full getSeats?login= refresh; wait for it to settle. + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(400); +} + +/** Clear book-for (Enter on an empty input resets to the admin's own login). */ +export async function clearBookFor(page: Page): Promise { + const bookForInput = page.locator('#book-for'); + await bookForInput.click(); + await bookForInput.fill(''); + await bookForInput.press('Enter'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(400); +} + /** Direct XHR to /xhr/plan/apply using the current page session (cookies). * Redirects are not followed: an expired session answers with a 302 to /login, * and following it would turn that into a misleading 200 (the login page). */ diff --git a/e2e/tests/admin/zones.spec.ts b/e2e/tests/admin/zones.spec.ts index 521b03b..8b28b34 100644 --- a/e2e/tests/admin/zones.spec.ts +++ b/e2e/tests/admin/zones.spec.ts @@ -74,7 +74,11 @@ test.describe('zone assignment', () => { await page.waitForLoadState('networkidle'); await expect(page.locator('#planmap')).toBeVisible(); await expect(page.locator('#auto_book_btn')).toHaveCount(0); - await expect(page.locator('#action_modal')).toHaveCount(0); + // The action modal HTML stays in the DOM for viewers so a pure viewer can + // still release their OWN booking from the plan map; the click handler + // opens it only for actionable seats (same rationale as the equivalent + // assertion in booking/zone-permissions.spec.ts). + await expect(page.locator('#action_modal')).toHaveCount(1); }); test('viewer-role user cannot book via API (code 104)', async ({ page }) => { diff --git a/e2e/tests/booking/bookas-zone-group.spec.ts b/e2e/tests/booking/bookfor-zone-group.spec.ts similarity index 64% rename from e2e/tests/booking/bookas-zone-group.spec.ts rename to e2e/tests/booking/bookfor-zone-group.spec.ts index 3efb467..73df66d 100644 --- a/e2e/tests/booking/bookas-zone-group.spec.ts +++ b/e2e/tests/booking/bookfor-zone-group.spec.ts @@ -1,19 +1,20 @@ /** - * Book-as interaction with zone-group / multi-plan booking. + * Book-for interaction with zone-group / multi-plan booking. * * These cover the bugs fixed alongside the unified zone-group conflict query: * - * - Book-as used to fetch only conflict seats (getSeats?onlyOtherZone=1). That + * - Book-for used to fetch only conflict seats (getSeats?onlyOtherZone=1). That * partial response also returned the target's bookings in *accessible* zones * that happened to share a conflict zone-group with the viewed plan, and the * client overwrote those live (rendered) seats with div-less "other zone" * ghosts — leaving the original sprite frozen on its stale TAKEN (padlock) - * icon. Book-as now does a full getSeats?login=target refresh, so the + * icon. Book-for now does a full getSeats?login=target refresh, so the * target's own bookings in accessible zones stay recognisable as theirs. * - * - `bookable` in getSeats now reflects the *target* user's role under book-as, - * so a seat the target can only view (VIEWER zone) is no longer offered as - * bookable to the admin (which apply() would then reject with 104). + * - `bookable` in getSeats reflects the *actor's* admin standing under book-for + * plus the target's membership in the zone (any role, viewers included) — so + * a seat in a zone the admin administers is offered as bookable even for a + * viewer target; apply() applies the same membership-only rule. */ import { test, expect } from '../../fixtures'; @@ -29,31 +30,11 @@ import { clickActionBtn, waitForSeatsLoaded, apiApply, + activateBookFor, + clearBookFor, + getSelectableDates, } from '../../helpers/booking'; -/** Activate book-as for the given display label (e.g. "Bar [user2]"). */ -async function activateBookAs(page: any, label: string): Promise { - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially(label.split(' ')[0], { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: label }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - // book-as fires a full getSeats?login= refresh; wait for it to settle. - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(400); -} - -/** Clear book-as (Enter on an empty input resets to the admin's own login). */ -async function clearBookAs(page: any): Promise { - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.fill(''); - await bookAsInput.press('Enter'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(400); -} - /** Open the action modal for a seat and return the set of visible action labels. */ async function seatActions(page: any, seat: any): Promise { await clickZoneSeat(page, seat); @@ -74,7 +55,7 @@ async function seatActions(page: any, seat: any): Promise { return actions; } -test.describe('book-as + zone group', () => { +test.describe('book-for + zone group', () => { test('target\'s own booking in an accessible same-group zone shows as deletable, not a padlock', async ({ page }) => { const ts = futureDayTs(1); @@ -100,7 +81,7 @@ test.describe('book-as + zone group', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); // The seat user2 already holds must be recognised as user2's own booking // (delete offered), not shown as taken-by-someone-else (padlock → admin-only @@ -110,7 +91,7 @@ test.describe('book-as + zone group', () => { await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); }); - test('rebooking across a same-group zone via book-as leaves consistent state after refresh', async ({ page }) => { + test('rebooking across a same-group zone via book-for leaves consistent state after refresh', async ({ page }) => { const ts = futureDayTs(1); const zone1Seat = (await getZoneSeats(1))[0]; const zone2Seat = (await getZoneSeats(2))[0]; @@ -137,7 +118,7 @@ test.describe('book-as + zone group', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); // Zone 1B seat is same-group → rebook (update), moving user2's booking. await clickZoneSeat(page, zone2Seat); @@ -165,7 +146,7 @@ test.describe('book-as + zone group', () => { await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); }); - test('book-as a viewer-only user marks the seat not bookable (Fix 5)', async ({ page }) => { + test('book-for a viewer-only user still offers booking when the admin administers the zone', async ({ page }) => { const ts = futureDayTs(1); // Zone 3 (Parking): user2 has no group role here, so a direct VIEWER role // is genuinely viewer-only (zone 1 would be polluted by group_1a's USER role). @@ -189,15 +170,15 @@ test.describe('book-as + zone group', () => { await page.keyboard.press('Escape'); await page.waitForTimeout(200); - // Book-as user2 (viewer): the seat is now view-only for user2, so clicking - // it must not open the booking modal (VIEW_ONLY short-circuits the handler). - await activateBookAs(page, 'Bar [user2]'); + // Book-for user2 (viewer): user1 administers this zone, so book-for overrides + // user2's viewer restriction — the seat is still bookable, not view-only. + await activateBookFor(page, 'Bar [user2]'); await clickZoneSeat(page, parkingSeat); - await page.waitForTimeout(300); - await expect(page.locator('#action_modal')).not.toHaveClass(/open/); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).toBeVisible(); }); - test('GUARD: getSeats sends login on book-as conflict seats (Fix 1 server contract)', async ({ page }) => { + test('GUARD: getSeats sends login on book-for conflict seats (Fix 1 server contract)', async ({ page }) => { // Deterministic contract guard. The target's only booking is in a zone the // admin cannot access, so it reaches the response purely via the conflict // query (an other-zone seat: no x/y). Fix 1 requires that conflict booking @@ -235,11 +216,16 @@ test.describe('book-as + zone group', () => { expect(conflictSeat.book[0].login).toBe(USER2.login); }); - test('book-as conflict seat in an inaccessible same-group zone drives rebook (client uses server login)', async ({ page }) => { + test('book-for conflict seat in an inaccessible same-group zone drives rebook (client uses server login)', async ({ page }) => { // UI counterpart of the contract guard: the inaccessible Parking booking - // must make the accessible Zone 1A seat a rebook under book-as. This passes + // must make the accessible Zone 1A seat a rebook under book-for. This passes // only if the conflict seat is recognised as the target's, i.e. its login - // arrived from the server and the client honoured it. + // arrived from the server and the client honoured it. The release sits in + // Parking, which user1 does NOT administer → release confinement blocks + // the doomed "update" (hasUnmanageableConflict): the seat is still a + // rebook (conflict recognised) but the update action is not offered and an + // explanatory message is shown instead. (The server-side 102 pin is the + // companion test "book-for cannot release a target's booking..." below.) const ts = futureDayTs(1); const zone1Seat = (await getZoneSeats(1))[0]; const parkingSeat = (await getZoneSeats(3))[0]; @@ -258,20 +244,98 @@ test.describe('book-as + zone group', () => { await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); + // The conflict was recognised (the seat is a rebook, not a plain book): + // book is NOT offered, and the update is blocked by release confinement. await clickZoneSeat(page, zone1Seat); await expect(page.locator('#action_modal')).toHaveClass(/open/); - await expect(page.locator('.plan_action_btn[data-action="update"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); - // The conflicting Parking booking is listed for removal (built by - // getMyConflictingBookings, which matches on the acting login). - await expect(page.locator('#action_modal_msg2')).toContainText('Parking'); + await expect(page.locator('#action_modal_msg1')).toContainText("don't administer"); + }); + + test('book-for CAN_CHANGE: update/release blocked when a same-group conflict lies in a non-administered zone', async ({ page }) => { + // Same confinement as the rebook test above, but the clicked seat holds + // the TARGET's own booking (CAN_CHANGE, not CAN_REBOOK): user2 holds the + // administered Zone 1A seat on day 1 and the inaccessible Parking seat + // (same group) on day 2. With both days selected, update/release from the + // Zone 1A seat would also have to remove the Parking booking — apply() + // would 403 (code 102) and roll everything back — so the modal offers + // neither action and shows the explanatory message instead. + const zone1Seat = (await getZoneSeats(1))[0]; + const parkingSeat = (await getZoneSeats(3))[0]; + + await querySql("UPDATE zone SET zone_group = 'floor-1' WHERE id IN (1, 3)"); + await querySql('UPDATE seat SET pid = 1 WHERE id = $1', [parkingSeat.id]); + await logIn(page, ADMIN); + await adminPost(page, '/xhr/zones/assign', { zid: 1, change: [{ login: USER1.login, role: 10 }] }); + + await logIn(page, USER1); + await page.goto('/plan/1'); + await waitForSeatsLoaded(page); + // Two selectable days straight from the rendered calendar (futureDayTs + // could land on an omitted weekday); disjoint days keep the two seeded + // same-group bookings clear of the book_overlap trigger. + const [day1, day2] = await getSelectableDates(page); + await querySql( + 'INSERT INTO book (login, sid, fromts, tots) VALUES ($1, $2, $3, $4), ($5, $6, $7, $8)', + [USER2.login, zone1Seat.id, day1 + 9 * 3600, day1 + 17 * 3600, + USER2.login, parkingSeat.id, day2 + 9 * 3600, day2 + 17 * 3600]); + await selectOnlyDates(page, [day1, day2]); + await page.waitForTimeout(400); + await activateBookFor(page, 'Bar [user2]'); // full refresh picks up the seeded bookings + + await clickZoneSeat(page, zone1Seat); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).not.toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); + await expect(page.locator('#action_modal_msg1')).toContainText("don't administer"); + }); + + test('book-for cannot release a target\'s booking in a non-administered same-group zone', async ({ page }) => { + // user1 administers Zone 1A but is only a regular USER in Parking (Zone 3), + // same group. user2 already holds the Parking seat. Booking user2 into Zone + // 1A while asking to release the Parking booking must fail — release + // confinement (apply()'s per-seat zone-admin check) blocks the remove bid, + // and the whole atomic request is rejected, not partially applied. + const ts = futureDayTs(1); + const zone1Seat = (await getZoneSeats(1))[0]; + const parkingSeat = (await getZoneSeats(3))[0]; + const fromTS = ts + 9 * 3600, toTS = ts + 17 * 3600; + + await querySql("UPDATE zone SET zone_group = 'floor-1' WHERE id IN (1, 3)"); + await logIn(page, ADMIN); + await adminPost(page, '/xhr/zones/assign', { zid: 1, change: [{ login: USER1.login, role: 10 }] }); + await adminPost(page, '/xhr/zones/assign', { zid: 3, change: [{ login: USER1.login, role: 20 }] }); // USER only, not admin + await adminPost(page, '/xhr/zones/assign', { zid: 1, change: [{ login: USER2.login, role: 20 }] }); + await adminPost(page, '/xhr/zones/assign', { zid: 3, change: [{ login: USER2.login, role: 20 }] }); + + await querySql( + 'INSERT INTO book (login, sid, fromts, tots) VALUES ($1, $2, $3, $4)', + [USER2.login, parkingSeat.id, fromTS, toTS]); + const bidRow = await querySql( + 'SELECT id FROM book WHERE login = $1 AND sid = $2', + [USER2.login, parkingSeat.id]); + const removeBid = bidRow.rows[0].id; + + await logIn(page, USER1); + const resp = await apiApply(page, { + book: { sid: zone1Seat.id, login: USER2.login, dates: [{ fromTS, toTS }] }, + remove: [removeBid], + }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(102); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, parkingSeat.id]); + expect(r.rows[0].cnt).toBe(1); }); }); -test.describe('book-as target switching', () => { +test.describe('book-for target switching', () => { // Helper: zones 1 & 2 share group 'floor-1'; user1 admins both; the given // users get USER access to Zone 1B (zid 2). Returns the two seats of interest. @@ -287,7 +351,7 @@ test.describe('book-as target switching', () => { return { zone1Seat, zone2Seat }; } - test('switching book-as back to self restores the admin\'s own (conflict-free) view', async ({ page }) => { + test('switching book-for back to self restores the admin\'s own (conflict-free) view', async ({ page }) => { const ts = futureDayTs(1); const { zone1Seat, zone2Seat } = await setupGroup(page); const fromTS = ts + 9 * 3600, toTS = ts + 17 * 3600; @@ -303,18 +367,18 @@ test.describe('book-as target switching', () => { await page.waitForTimeout(400); // As user2: Zone 1B seat is a rebook (user2's same-group Zone 1A booking). - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); expect(await seatActions(page, zone2Seat)).toContain('update'); expect(await seatActions(page, zone2Seat)).not.toContain('book'); // Back to self (admin user1, who holds nothing): plain book, no rebook. - await clearBookAs(page); + await clearBookFor(page); const selfActions = await seatActions(page, zone2Seat); expect(selfActions).toContain('book'); expect(selfActions).not.toContain('update'); }); - test('switching book-as between two targets reflects each target\'s own conflicts', async ({ page }) => { + test('switching book-for between two targets reflects each target\'s own conflicts', async ({ page }) => { const ts = futureDayTs(1); const { zone1Seat, zone2Seat } = await setupGroup(page, [USER3.login]); const fromTS = ts + 9 * 3600, toTS = ts + 17 * 3600; @@ -330,16 +394,16 @@ test.describe('book-as target switching', () => { await page.waitForTimeout(400); // user2 → rebook (has conflict); user3 → plain book (no conflict). - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); expect(await seatActions(page, zone2Seat)).toContain('update'); - await activateBookAs(page, 'Baz [user3]'); + await activateBookFor(page, 'Baz [user3]'); const u3 = await seatActions(page, zone2Seat); expect(u3).toContain('book'); expect(u3).not.toContain('update'); // Back to user2 → rebook again (no stale ghost from the user3 view). - await activateBookAs(page, 'Bar [user2]'); + await activateBookFor(page, 'Bar [user2]'); expect(await seatActions(page, zone2Seat)).toContain('update'); }); diff --git a/e2e/tests/booking/zone-group.spec.ts b/e2e/tests/booking/zone-group.spec.ts index cca44cc..e0f0099 100644 --- a/e2e/tests/booking/zone-group.spec.ts +++ b/e2e/tests/booking/zone-group.spec.ts @@ -25,6 +25,7 @@ import { clickActionBtn, waitForSeatsLoaded, apiApply, + activateBookFor, } from '../../helpers/booking'; test.describe('per-zone booking constraint', () => { @@ -356,7 +357,7 @@ test.describe('zone group constraint (non-null zone_group)', () => { expect(Number(rows.rows[0].sid)).toBe(zone2Seat.id); }); - test('UI: book-as shows rebook icon when target has same-group booking on another plan', async ({ page }) => { + test('UI: book-for shows rebook icon when target has same-group booking on another plan', async ({ page }) => { const ts = futureDayTs(1); const zone1Seat = (await getZoneSeats(1))[0]; const zone2Seat = (await getZoneSeats(2))[0]; @@ -375,21 +376,14 @@ test.describe('zone group constraint (non-null zone_group)', () => { await logIn(page, USER2); await apiApply(page, { book: { sid: zone1Seat.id, dates: [{ fromTS, toTS }] } }); - // user1 (admin of zone 1B) opens plan 2 and book-as user2 + // user1 (admin of zone 1B) opens plan 2 and books for user2 await logIn(page, USER1); await page.goto('/plan/2'); await waitForSeatsLoaded(page); await selectOnlyDates(page, [ts]); await page.waitForTimeout(400); - // Activate book-as for user2 - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially('Bar', { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - await page.waitForTimeout(400); + await activateBookFor(page, 'Bar [user2]'); // Click Zone 1B's seat — should show "update" (rebook) because user2 // already has a same-group booking on plan 1 diff --git a/e2e/tests/booking/zone-permissions-bookas.spec.ts b/e2e/tests/booking/zone-permissions-bookfor.spec.ts similarity index 72% rename from e2e/tests/booking/zone-permissions-bookas.spec.ts rename to e2e/tests/booking/zone-permissions-bookfor.spec.ts index e12df08..339c2a9 100644 --- a/e2e/tests/booking/zone-permissions-bookas.spec.ts +++ b/e2e/tests/booking/zone-permissions-bookfor.spec.ts @@ -1,5 +1,5 @@ /** - * Zone permission edge cases around "book as" (acting on behalf of another user), + * Zone permission edge cases around "book for" (acting on behalf of another user), * site-admin super-user access, multi-zone plans, and public zones mixed with * regular/enabled zones. * @@ -8,47 +8,53 @@ * A. Site admin (account_type 10) is a super-user over every zone, even ones * they are not explicitly assigned to: * A1 view + self-book any plan with no zone assignment - * A2 book-as a user who has access, in an unassigned zone - * A3 cannot book-as a user who has no access to the zone + * A2 book-for a user who has access, in an unassigned zone + * A3 cannot book-for a user who has no access to the zone * A4 still cannot book a DISABLED-zone seat * - * B. Manual book-as by a *zone* admin, in multi-zone / public-mixed plans: - * B1 zone admin books-as in the zone they administer - * B2 zone admin CANNOT book-as in a PUBLIC_BOOK zone they don't administer - * B3 admin of a PUBLIC_BOOK zone can book-as anyone (public ⇒ USER) - * B4 admin of a PUBLIC_VIEW zone can book-as an explicit USER but not a - * view-only (public VIEWER) user + * B. Manual book-for by a *zone* admin, in multi-zone / public-mixed plans: + * B1 zone admin books-for in the zone they administer + * B2 zone admin CANNOT book-for in a PUBLIC_BOOK zone they don't administer + * B3 admin of a PUBLIC_BOOK zone can book-for anyone (public ⇒ USER) + * B4 admin of a PUBLIC_VIEW zone can book-for both an explicit USER and a + * view-only (public VIEWER) user — book-for only requires membership + * B5 book-for overrides a seat assignment to someone else + * B6 book-for overrides a seat's days-in-advance assignment window * - * C. Auto-book ("find me a seat") as another user: - * C1 zone admin can auto-book-as (previously wrongly rejected with 403) - * C2 auto-book-as is confined to the zones the actor administers - * C3 site admin can auto-book-as anyone - * C4 a non-admin cannot auto-book-as - * C5 zone admin can auto-book-as via the FAB (UI) + * C. Auto-book ("find me a seat") for another user: + * C1 zone admin can auto-book-for (previously wrongly rejected with 403) + * C2 auto-book-for is confined to the zones the actor administers + * C3 site admin can auto-book-for anyone + * C4 a non-admin cannot auto-book-for + * C5 zone admin can auto-book-for via the FAB (UI) + * C12 zone admin can auto-book-for a viewer into a zone they administer * - * D. Multi-zone exclusivity (book_overlap trigger) combined with book-as: - * D1 same zone-group ⇒ one seat per group (second book-as rejected, 109) + * D. Multi-zone exclusivity (book_overlap trigger) combined with book-for: + * D1 same zone-group ⇒ one seat per group (second book-for rejected, 109) * D2 ungrouped zones ⇒ a user may hold a seat in each simultaneously * - * E. getSeats book-as guard: - * E1 a non-admin cannot use the ?login= book-as parameter + * E. getSeats book-for guard: + * E1 a non-admin cannot use the ?login= book-for parameter */ import { test, expect } from '../../fixtures'; import { logIn } from '../../helpers/auth'; import { ADMIN, USER1, USER2, USER3 } from '../../helpers/users'; import { querySql } from '../../helpers/db'; -import { futureDayTs, apiApply, waitForSeatsLoaded } from '../../helpers/booking'; +import { futureDayTs, apiApply, waitForSeatsLoaded, activateBookFor } from '../../helpers/booking'; import { pickFirstDate } from '../../helpers/zone-admin'; import { + ZONE_TYPE_DISABLED, ZONE_TYPE_ENABLED, ZONE_TYPE_PUBLIC_VIEW, ZONE_TYPE_PUBLIC_BOOK, ZONE_ROLE_ADMIN, ZONE_ROLE_USER, + ZONE_ROLE_VIEWER, createPlan, createZone, addSeats, assignZoneRole, + assignSeat, clearZoneRoles, countBookings, insertBooking, @@ -84,8 +90,8 @@ test.describe('site admin is a super-user over all zones', () => { expect(await countBookings('admin', seatId)).toBe(1); }); - test('A2: can book as a user who has access, in an unassigned zone', async ({ page }) => { - const pid = await createPlan('Superuser BookAs Plan'); + test('A2: can book for a user who has access, in an unassigned zone', async ({ page }) => { + const pid = await createPlan('Superuser BookFor Plan'); const zid = await createZone('Enabled X', ZONE_TYPE_ENABLED); const [seatId] = await addSeats(pid, zid, ['X.1']); await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); @@ -98,7 +104,7 @@ test.describe('site admin is a super-user over all zones', () => { expect(await countBookings('admin', seatId)).toBe(0); }); - test('A3: cannot book as a user who has no access to the zone', async ({ page }) => { + test('A3: cannot book for a user who has no access to the zone', async ({ page }) => { const pid = await createPlan('Superuser NoAccess Plan'); const zid = await createZone('Enabled Y', ZONE_TYPE_ENABLED); const [seatId] = await addSeats(pid, zid, ['Y.1']); @@ -112,7 +118,7 @@ test.describe('site admin is a super-user over all zones', () => { test('A4: still cannot book a DISABLED-zone seat even as super-user', async ({ page }) => { const pid = await createPlan('Superuser Disabled Plan'); - const zid = await createZone('Disabled Z', 10 /* DISABLED */); + const zid = await createZone('Disabled Z', ZONE_TYPE_DISABLED); const [seatId] = await addSeats(pid, zid, ['Z.1']); await clearZoneRoles('admin'); @@ -124,10 +130,10 @@ test.describe('site admin is a super-user over all zones', () => { }); // --------------------------------------------------------------------------- -// B. Manual book-as by a zone admin in multi-zone / public-mixed plans +// B. Manual book-for by a zone admin in multi-zone / public-mixed plans // --------------------------------------------------------------------------- -test.describe('manual book-as in multi-zone / public-mixed plans', () => { +test.describe('manual book-for in multi-zone / public-mixed plans', () => { /** Plan with an ENABLED zone (user1 admin, user2 user) + a PUBLIC_BOOK zone. */ async function setupEnabledPlusPublicBook() { @@ -142,7 +148,7 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { return { pid, enabledZid, publicZid, enabledSeat, publicSeat }; } - test('B1: zone admin books-as a user in the zone they administer', async ({ page }) => { + test('B1: zone admin books-for a user in the zone they administer', async ({ page }) => { const { enabledSeat } = await setupEnabledPlusPublicBook(); await logIn(page, USER1); const resp = await apiApply(page, { book: { sid: enabledSeat, login: 'user2', dates: [slot(1)] } }); @@ -150,7 +156,7 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(await countBookings('user2', enabledSeat)).toBe(1); }); - test('B2: zone admin CANNOT book-as in a public-book zone they do not administer', async ({ page }) => { + test('B2: zone admin CANNOT book-for in a public-book zone they do not administer', async ({ page }) => { const { publicSeat } = await setupEnabledPlusPublicBook(); await logIn(page, USER1); // user1 is admin of the enabled zone but only an implicit USER of the public zone @@ -160,8 +166,8 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(await countBookings('user2', publicSeat)).toBe(0); }); - test('B3: admin of a PUBLIC_BOOK zone can book-as any user', async ({ page }) => { - const pid = await createPlan('PublicBook BookAs Plan'); + test('B3: admin of a PUBLIC_BOOK zone can book-for any user', async ({ page }) => { + const pid = await createPlan('PublicBook BookFor Plan'); const zid = await createZone('PB Zone', ZONE_TYPE_PUBLIC_BOOK); const [seatId] = await addSeats(pid, zid, ['PB.1']); await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); @@ -173,8 +179,8 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(await countBookings('user3', seatId)).toBe(1); }); - test('B4: admin of a PUBLIC_VIEW zone can book-as an explicit USER but not a view-only user', async ({ page }) => { - const pid = await createPlan('PublicView BookAs Plan'); + test('B4: admin of a PUBLIC_VIEW zone can book-for both an explicit USER and a view-only user', async ({ page }) => { + const pid = await createPlan('PublicView BookFor Plan'); const zid = await createZone('PV Zone', ZONE_TYPE_PUBLIC_VIEW); const [seatId] = await addSeats(pid, zid, ['PV.1']); await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); @@ -186,19 +192,54 @@ test.describe('manual book-as in multi-zone / public-mixed plans', () => { expect(ok.status()).toBe(200); expect(await countBookings('user2', seatId)).toBe(1); - // user3 only has implicit VIEWER via PUBLIC_VIEW → cannot be booked for - const denied = await apiApply(page, { book: { sid: seatId, login: 'user3', dates: [slot(2)] } }); - expect(denied.status()).toBe(403); - expect((await denied.json()).code).toBe(104); - expect(await countBookings('user3', seatId)).toBe(0); + // user3 only has implicit VIEWER via PUBLIC_VIEW — under book-for, membership + // (not role <= USER) is all that's required, since the admin overrides the + // viewer restriction for this booking. + const alsoOk = await apiApply(page, { book: { sid: seatId, login: 'user3', dates: [slot(2)] } }); + expect(alsoOk.status()).toBe(200); + expect(await countBookings('user3', seatId)).toBe(1); + }); + + test('B5: book-for overrides a seat assignment to someone else', async ({ page }) => { + const pid = await createPlan('BookFor Assignment Override Plan'); + const zid = await createZone('BA Assign Zone', ZONE_TYPE_ENABLED); + const [seatId] = await addSeats(pid, zid, ['BA.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await assignZoneRole(zid, 'user3', ZONE_ROLE_USER); + await assignSeat(seatId, 'user3'); // seat is assigned to user3 only + + await logIn(page, USER1); + // A regular (non-book-for) booking by user2 would be rejected with 106 — + // but the zone admin booking FOR user2 overrides the assignment. + const resp = await apiApply(page, { book: { sid: seatId, login: 'user2', dates: [slot(1)] } }); + expect(resp.status()).toBe(200); + expect(await countBookings('user2', seatId)).toBe(1); + }); + + test('B6: book-for overrides a seat\'s days-in-advance assignment window', async ({ page }) => { + const pid = await createPlan('BookFor Days-In-Advance Override Plan'); + const zid = await createZone('BA Dia Zone', ZONE_TYPE_ENABLED); + const [seatId] = await addSeats(pid, zid, ['DIA.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await assignSeat(seatId, 'user2', 0); // user2 may normally only book 0 days out + + await logIn(page, USER1); + // 3 days out: beyond the assignment's 0-days-in-advance window (would + // normally be 110), but within the default 1-week global booking horizon + // (code 103), so this isolates the assignment-window override. + const resp = await apiApply(page, { book: { sid: seatId, login: 'user2', dates: [slot(3)] } }); + expect(resp.status()).toBe(200); + expect(await countBookings('user2', seatId)).toBe(1); }); }); // --------------------------------------------------------------------------- -// C. Auto-book ("find me a seat") as another user +// C. Auto-book ("find me a seat") for another user // --------------------------------------------------------------------------- -test.describe('auto-book as another user', () => { +test.describe('auto-book for another user', () => { async function autoBook(page: any, pid: number, dates: object[], login?: string) { const data: any = { dates }; @@ -210,8 +251,8 @@ test.describe('auto-book as another user', () => { }); } - test('C1: a zone admin can auto-book as another user', async ({ page }) => { - const pid = await createPlan('AutoBookAs Plan'); + test('C1: a zone admin can auto-book for another user', async ({ page }) => { + const pid = await createPlan('AutoBookFor Plan'); const zid = await createZone('ABA Zone', ZONE_TYPE_ENABLED); await addSeats(pid, zid, ['A.1', 'A.2']); await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); @@ -229,8 +270,8 @@ test.describe('auto-book as another user', () => { expect(r.rows[0].cnt).toBeGreaterThan(0); }); - test('C2: auto-book-as is confined — cannot book into a zone the actor does not administer', async ({ page }) => { - const pid = await createPlan('AutoBookAs Confined Plan'); + test('C2: auto-book-for is confined — cannot book into a zone the actor does not administer', async ({ page }) => { + const pid = await createPlan('AutoBookFor Confined Plan'); const zidA = await createZone('Conf A', ZONE_TYPE_ENABLED); const zidB = await createZone('Conf B', ZONE_TYPE_ENABLED); const [seatA] = await addSeats(pid, zidA, ['CA.1']); @@ -238,7 +279,7 @@ test.describe('auto-book as another user', () => { await assignZoneRole(zidA, 'user1', ZONE_ROLE_ADMIN); await assignZoneRole(zidB, 'user2', ZONE_ROLE_USER); // user2 has no role in zone A, and user1 is not admin of zone B. - // user1 auto-books-as user2: subject has no accessible seat in user1's managed zone A, + // user1 auto-books-for user2: subject has no accessible seat in user1's managed zone A, // so the endpoint rejects with 403/104. await logIn(page, USER1); @@ -249,8 +290,8 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user2', seatB)).toBe(0); }); - test('C2b: auto-book-as lands the subject in a zone the actor administers', async ({ page }) => { - const pid = await createPlan('AutoBookAs Positive Plan'); + test('C2b: auto-book-for lands the subject in a zone the actor administers', async ({ page }) => { + const pid = await createPlan('AutoBookFor Positive Plan'); const zidA = await createZone('Pos A', ZONE_TYPE_ENABLED); const zidB = await createZone('Pos B', ZONE_TYPE_ENABLED); const [seatA] = await addSeats(pid, zidA, ['PA.1']); @@ -259,7 +300,7 @@ test.describe('auto-book as another user', () => { await assignZoneRole(zidA, 'user2', ZONE_ROLE_USER); await assignZoneRole(zidB, 'user2', ZONE_ROLE_USER); // user1 administers zone A only; user2 has USER in both A and B. - // Auto-book-as must confine to zone A — user2 lands in A, never B. + // Auto-book-for must confine to zone A — user2 lands in A, never B. await logIn(page, USER1); const resp = await autoBook(page, pid, [slot(1)], 'user2'); @@ -270,8 +311,8 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user2', seatB)).toBe(0); }); - test('C3: a site admin can auto-book as anyone', async ({ page }) => { - const pid = await createPlan('AutoBookAs Admin Plan'); + test('C3: a site admin can auto-book for anyone', async ({ page }) => { + const pid = await createPlan('AutoBookFor Admin Plan'); const zid = await createZone('ABA Admin Zone', ZONE_TYPE_ENABLED); await addSeats(pid, zid, ['AA.1']); await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); @@ -288,8 +329,8 @@ test.describe('auto-book as another user', () => { expect(r.rows[0].cnt).toBeGreaterThan(0); }); - test('C4: a non-admin cannot auto-book as another user', async ({ page }) => { - const pid = await createPlan('AutoBookAs Denied Plan'); + test('C4: a non-admin cannot auto-book for another user', async ({ page }) => { + const pid = await createPlan('AutoBookFor Denied Plan'); const zid = await createZone('ABA Denied Zone', ZONE_TYPE_ENABLED); await addSeats(pid, zid, ['AD.1']); await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); // user2 is only a USER here @@ -300,7 +341,7 @@ test.describe('auto-book as another user', () => { expect((await resp.json()).code).toBe(104); }); - test('C5: a zone admin can auto-book as another user via the FAB (UI)', async ({ page }) => { + test('C5: a zone admin can auto-book for another user via the FAB (UI)', async ({ page }) => { // Plan 1 / Zone 1A: user1 is admin, user2 is a USER (via group_1a). await logIn(page, USER1); await page.goto('/plan/1'); @@ -308,13 +349,7 @@ test.describe('auto-book as another user', () => { await pickFirstDate(page); await page.waitForTimeout(400); - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially('Bar', { delay: 50 }); - const item = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); - await expect(item).toBeVisible({ timeout: 5000 }); - await item.click(); - await page.waitForTimeout(200); + await activateBookFor(page, 'Bar [user2]'); const [resp] = await Promise.all([ page.waitForResponse(r => r.url().includes('/xhr/plan/autoBook')), @@ -391,7 +426,7 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user3', seatId)).toBe(1); }); - test('C10: release gate — auto-book-as cannot release a booking in an unmanaged same-group zone', async ({ page }) => { + test('C10: release gate — auto-book-for cannot release a booking in an unmanaged same-group zone', async ({ page }) => { const pid = await createPlan('Release Gate Plan'); const zidA = await createZone('RelGrp A', ZONE_TYPE_ENABLED, 'relGrp'); const zidB = await createZone('RelGrp B', ZONE_TYPE_ENABLED, 'relGrp'); @@ -402,7 +437,7 @@ test.describe('auto-book as another user', () => { await assignZoneRole(zidB, 'user2', ZONE_ROLE_USER); // user1 administers zone A only; zone B is in the same group but not administered. const s = slot(1); - // Pre-book user2 in zone B for the morning only (09:00-13:00). Auto-book-as + // Pre-book user2 in zone B for the morning only (09:00-13:00). Auto-book-for // requests the full day (09:00-17:00) — the exact-match shortcut won't fire // because the times differ. A candidate in zone A would conflict with B's // same-group booking, and can_release must block releasing B's booking. @@ -451,16 +486,31 @@ test.describe('auto-book as another user', () => { expect(await countBookings('user2', seatA)).toBe(1); expect(await countBookings('user2', seatB)).toBe(1); }); + + test('C12: a zone admin can auto-book for a viewer into a zone they administer', async ({ page }) => { + const pid = await createPlan('AutoBookFor Viewer Plan'); + const zid = await createZone('ABA Viewer Zone', ZONE_TYPE_ENABLED); + const [seatId] = await addSeats(pid, zid, ['AV.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_VIEWER); // viewer only, not a booking role + + await logIn(page, USER1); + const resp = await autoBook(page, pid, [slot(1)], 'user2'); + expect(resp.status()).toBe(200); + const body = await resp.json(); + expect(body.booked.length).toBe(1); + expect(await countBookings('user2', seatId)).toBe(1); + }); }); // --------------------------------------------------------------------------- -// D. Multi-zone exclusivity (book_overlap trigger) combined with book-as +// D. Multi-zone exclusivity (book_overlap trigger) combined with book-for // --------------------------------------------------------------------------- -test.describe('multi-zone exclusivity with book-as', () => { +test.describe('multi-zone exclusivity with book-for', () => { - test('D1: same zone-group enforces one seat per group (second book-as rejected)', async ({ page }) => { - const pid = await createPlan('Group BookAs Plan'); + test('D1: same zone-group enforces one seat per group (second book-for rejected)', async ({ page }) => { + const pid = await createPlan('Group BookFor Plan'); const zidA = await createZone('Grp A', ZONE_TYPE_ENABLED, 'grpG'); const zidB = await createZone('Grp B', ZONE_TYPE_ENABLED, 'grpG'); const [seatA] = await addSeats(pid, zidA, ['GA.1']); @@ -533,12 +583,12 @@ test.describe('multi-zone exclusivity with book-as', () => { }); // --------------------------------------------------------------------------- -// E. getSeats book-as guard +// E. getSeats book-for guard // --------------------------------------------------------------------------- -test.describe('getSeats book-as guard', () => { +test.describe('getSeats book-for guard', () => { - test('E1: a non-admin cannot use the book-as query parameters', async ({ page }) => { + test('E1: a non-admin cannot use the book-for query parameters', async ({ page }) => { // Zone 1A is enabled by default; user2 is a USER there (via group_1a), not an admin. await logIn(page, USER2); const resp = await page.request.get('/xhr/plan/getSeats/1?login=user1', { maxRedirects: 0 }); @@ -548,7 +598,7 @@ test.describe('getSeats book-as guard', () => { }); // --------------------------------------------------------------------------- -// F. Cross-zone book-as release confinement (security regression) +// F. Cross-zone book-for release confinement (security regression) // // Guards the invariant that a zone admin who administers only Z1 (but is a // mere USER in Z2, same zone group) can NEVER release another user's Z2 booking @@ -559,7 +609,7 @@ test.describe('getSeats book-as guard', () => { // (See plan-expand-user-to-zone-roles.md §3.3.) // --------------------------------------------------------------------------- -test.describe('cross-zone book-as release confinement', () => { +test.describe('cross-zone book-for release confinement', () => { async function autoBook(page: any, pid: number, dates: object[], login?: string) { const data: any = { dates }; @@ -571,10 +621,10 @@ test.describe('cross-zone book-as release confinement', () => { }); } - test('F1: auto-book-as never releases a booking in an unmanaged same-group zone', async ({ page }) => { + test('F1: auto-book-for never releases a booking in an unmanaged same-group zone', async ({ page }) => { // Z1 and Z2 share a zone group. A1 (user1) is ADMIN on Z1 but only USER on // Z2. U (user2) has USER access to both. U already holds a seat in Z2. - // A1 auto-books-as U for the same day: a Z1 candidate would conflict with U's + // A1 auto-books-for U for the same day: a Z1 candidate would conflict with U's // same-group Z2 booking, but can_release must reject releasing it (Z2 is not // in A1's manageableZids). The day is returned unbookable / not_extended, and // U's Z2 booking survives untouched. @@ -589,7 +639,7 @@ test.describe('cross-zone book-as release confinement', () => { await assignZoneRole(zid2, 'user2', ZONE_ROLE_USER); // U can book Z2 const s = slot(1); - // U holds a partial-day booking in Z2 (09:00-13:00). Auto-book-as requests + // U holds a partial-day booking in Z2 (09:00-13:00). Auto-book-for requests // the full day (09:00-17:00), so the exact-match shortcut won't fire and a // Z1 candidate would conflict with U's same-group Z2 booking — can_release // must block releasing it (Z2 is not in A1's manageableZids). @@ -638,7 +688,7 @@ test.describe('cross-zone book-as release confinement', () => { test('F3: apply() book only inserts — a same-group conflict is rejected, never silently released', async ({ page }) => { // The book action never releases a conflicting booking: it inserts only, and - // the DB trigger rejects a same-group overlap (109). A1 books-as U on Z1 for + // the DB trigger rejects a same-group overlap (109). A1 books-for U on Z1 for // the same slot U already holds in Z2 (same group) → 109, and U's Z2 booking // is untouched. const pid = await createPlan('XConf Book Plan'); @@ -664,3 +714,58 @@ test.describe('cross-zone book-as release confinement', () => { expect(await countBookings('user2', seat1)).toBe(0); }); }); + +// --------------------------------------------------------------------------- +// D. Book-for onto a disabled seat: a zone admin may override a +// seat-level disable (they could re-enable it anyway) when booking FOR a +// target. The zone-type DISABLED block (104) still applies — only the +// seat-enabled check (105) is skipped under is_book_for. Self-booking onto a +// disabled seat stays 105, and auto-book-for never picks a disabled seat. +// --------------------------------------------------------------------------- + +async function disableSeat(seatId: number): Promise { + await querySql('UPDATE seat SET enabled = false WHERE id = $1', [seatId]); +} + +test.describe('book-for onto a disabled seat (zone-admin override)', () => { + test('D1: book-for onto a disabled seat -> 200; self-book onto it -> 403 / 105', async ({ page }) => { + const pid = await createPlan('BookFor Disabled Seat Plan'); + const zid = await createZone('BFD Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['BFD.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await disableSeat(seat); + + await logIn(page, USER1); + // Book-for user2 onto the disabled seat -> the admin overrides the disable. + const ok = await apiApply(page, { book: { sid: seat, login: 'user2', dates: [slot(1)] } }); + expect(ok.status()).toBe(200); + expect(await countBookings('user2', seat)).toBe(1); + + // Clear it and try a self-book onto the same disabled seat -> 105. + await querySql('DELETE FROM book WHERE sid = $1', [seat]); + const denied = await apiApply(page, { book: { sid: seat, dates: [slot(1)] } }); + expect(denied.status()).toBe(403); + expect((await denied.json()).code).toBe(105); + expect(await countBookings('user1', seat)).toBe(0); + }); + + test('D2: auto-book-for never picks a disabled seat (only free seat -> booked empty)', async ({ page }) => { + const pid = await createPlan('AutoBookFor Disabled Plan'); + const zid = await createZone('ABD Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['ABD.1']); + await assignZoneRole(zid, 'user1', ZONE_ROLE_ADMIN); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + await disableSeat(seat); // the only seat, and it is disabled + + await logIn(page, USER1); + const resp = await page.request.post(`/xhr/plan/autoBook/${pid}`, { + data: { dates: [slot(1)], login: 'user2' }, + headers: { 'Content-Type': 'application/json' }, + maxRedirects: 0, + }); + expect(resp.status()).toBe(200); + expect((await resp.json()).booked).toEqual([]); + expect(await countBookings('user2', seat)).toBe(0); + }); +}); diff --git a/e2e/tests/booking/zone-permissions.spec.ts b/e2e/tests/booking/zone-permissions.spec.ts index 571e941..778d6fa 100644 --- a/e2e/tests/booking/zone-permissions.spec.ts +++ b/e2e/tests/booking/zone-permissions.spec.ts @@ -424,10 +424,12 @@ test.describe('plan with all view-only zones → isZoneViewer', () => { await page.goto('/plan/1'); await waitForSeatsLoaded(page); - // The action modal HTML should not exist (viewer mode hides it) - await expect(page.locator('#action_modal')).toHaveCount(0); + // The action modal HTML stays in the DOM for viewers so a pure viewer can + // still release their OWN booking from the plan map; the click handler opens + // it only for an own booking (CAN_DELETE*), never for non-actionable seats. + await expect(page.locator('#action_modal')).toHaveCount(1); - // Auto-book FAB should not exist either + // Auto-book FAB must not exist (viewers can't auto-book). await expect(page.locator('#auto_book_btn')).toHaveCount(0); }); }); diff --git a/e2e/tests/booking/zone-shrink-bypass.spec.ts b/e2e/tests/booking/zone-shrink-bypass.spec.ts new file mode 100644 index 0000000..c70c8eb --- /dev/null +++ b/e2e/tests/booking/zone-shrink-bypass.spec.ts @@ -0,0 +1,172 @@ +/** + * The pure-shrink bypass in apply(). + * + * Invariant (PERMISSIONS.md §8): any operation that strictly shrinks the + * actor's own bookings is always allowed, everywhere — release is the + * shrink-to-zero case (already ungated); this generalises it to a shrink to a + * subset. A self update (no book.login) whose every booked range is fully + * covered by one of the actor's own bookings being removed on that seat skips + * every booking check (role 104, DISABLED zone 104, seat-disabled 105, + * assignment 106/110, horizon 103). Book-for is never a shrink. + * + * These are apply()-code-level tests (apiApply), matching the style of + * zone-permissions-bookfor.spec.ts: the bypass is a server-side predicate on + * the book+remove payload, so the assertions are on HTTP status + code and on + * the persisted booking row seeded/read via the DB backchannel. + */ +import { test, expect } from '../../fixtures'; +import { logIn } from '../../helpers/auth'; +import { USER1, USER3 } from '../../helpers/users'; +import { querySql } from '../../helpers/db'; +import { futureDayTs, apiApply } from '../../helpers/booking'; +import { + ZONE_TYPE_DISABLED, + ZONE_TYPE_ENABLED, + ZONE_TYPE_PUBLIC_VIEW, + ZONE_ROLE_USER, + createPlan, + createZone, + addSeats, + assignZoneRole, + assignSeat, + insertBooking, +} from '../../helpers/zone-setup'; + +const DAY = futureDayTs(1); +const FULL = { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 }; +const NARROW = { fromTS: DAY + 10 * 3600, toTS: DAY + 16 * 3600 }; +const EXTEND = { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 + 3600 }; + +/** The booking id for a (login, sid, fromts) triple — the remove bid. */ +async function bookingBid(login: string, sid: number, fromTS: number): Promise { + const r = await querySql( + 'SELECT id::int AS id FROM book WHERE login = $1 AND sid = $2 AND fromts = $3', + [login, sid, fromTS], + ); + expect(r.rows.length).toBe(1); + return r.rows[0].id; +} + +// --------------------------------------------------------------------------- +// Viewer zone (PUBLIC_VIEW): pure shrink allowed, extend still 104 +// --------------------------------------------------------------------------- + +test.describe('pure-shrink bypass: viewer zone', () => { + test('shrink own booking -> 200, booking replaced', async ({ page }) => { + const pid = await createPlan('Shrink Viewer Plan', 1); + const zid = await createZone('Shrink Viewer', ZONE_TYPE_PUBLIC_VIEW); + const [seat] = await addSeats(pid, zid, ['SV.1']); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + const bid = await bookingBid(USER3.login, seat, FULL.fromTS); + + await logIn(page, USER3); // pure viewer (no explicit role) + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] }, remove: [bid] }); + expect(resp.status()).toBe(200); + + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, seat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(NARROW.fromTS); + expect(after.rows[0].t).toBe(NARROW.toTS); + }); + + test('extend own booking (not a shrink) -> 403 / 104', async ({ page }) => { + const pid = await createPlan('Extend Viewer Plan', 1); + const zid = await createZone('Extend Viewer', ZONE_TYPE_PUBLIC_VIEW); + const [seat] = await addSeats(pid, zid, ['EV.1']); + // Booking narrower than the requested range so the request extends it. + await insertBooking(USER3.login, seat, NARROW.fromTS, NARROW.toTS); + const bid = await bookingBid(USER3.login, seat, NARROW.fromTS); + + await logIn(page, USER3); + const resp = await apiApply(page, { book: { sid: seat, dates: [EXTEND] }, remove: [bid] }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(104); + }); + + test('shrink without the matching remove bid -> 403 / 104', async ({ page }) => { + const pid = await createPlan('ShrinkNoRemove Viewer Plan', 1); + const zid = await createZone('ShrinkNoRemove Viewer', ZONE_TYPE_PUBLIC_VIEW); + const [seat] = await addSeats(pid, zid, ['SNR.1']); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + + await logIn(page, USER3); + // No remove bid -> is_pure_shrink is false -> the viewer role check fires. + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] } }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(104); + }); +}); + +// --------------------------------------------------------------------------- +// DISABLED zone: a shrink of an own booking is still allowed +// --------------------------------------------------------------------------- + +test.describe('pure-shrink bypass: DISABLED zone', () => { + test('shrink own booking in a DISABLED zone -> 200', async ({ page }) => { + const pid = await createPlan('Shrink Disabled Plan', 1); + const zid = await createZone('Shrink Disabled', ZONE_TYPE_DISABLED); + const [seat] = await addSeats(pid, zid, ['SD.1']); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + const bid = await bookingBid(USER3.login, seat, FULL.fromTS); + + await logIn(page, USER3); + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] }, remove: [bid] }); + expect(resp.status()).toBe(200); + + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, seat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(NARROW.fromTS); + expect(after.rows[0].t).toBe(NARROW.toTS); + }); +}); + +// --------------------------------------------------------------------------- +// Seat assigned to someone else: the assignment checks (106/110) are bypassed +// --------------------------------------------------------------------------- + +test.describe('pure-shrink bypass: assigned-to-another seat', () => { + test('shrink own booking on a seat assigned to another -> 200', async ({ page }) => { + const pid = await createPlan('Shrink Assigned Plan', 1); + const zid = await createZone('Shrink Assigned', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SA.1']); + await assignZoneRole(zid, USER3.login, ZONE_ROLE_USER); + // Seat assigned to user1; user3 nonetheless holds a booking (seeded) — + // shrinking it must bypass the assignment check (106). + await assignSeat(seat, USER1.login, null); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + const bid = await bookingBid(USER3.login, seat, FULL.fromTS); + + await logIn(page, USER3); + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] }, remove: [bid] }); + expect(resp.status()).toBe(200); + + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, seat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(NARROW.fromTS); + expect(after.rows[0].t).toBe(NARROW.toTS); + }); + + test('shrink without remove on an assigned-to-another seat -> 403 / 106', async ({ page }) => { + const pid = await createPlan('ShrinkNoRemove Assigned Plan', 1); + const zid = await createZone('ShrinkNoRemove Assigned', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SNA.1']); + await assignZoneRole(zid, USER3.login, ZONE_ROLE_USER); + await assignSeat(seat, USER1.login, null); + await insertBooking(USER3.login, seat, FULL.fromTS, FULL.toTS); + + await logIn(page, USER3); + // No remove -> not a pure shrink -> the assignment check (106) fires. + const resp = await apiApply(page, { book: { sid: seat, dates: [NARROW] } }); + expect(resp.status()).toBe(403); + expect((await resp.json()).code).toBe(106); + }); +}); \ No newline at end of file diff --git a/e2e/tests/booking/zone-view-sprites.spec.ts b/e2e/tests/booking/zone-view-sprites.spec.ts new file mode 100644 index 0000000..23526a6 --- /dev/null +++ b/e2e/tests/booking/zone-view-sprites.spec.ts @@ -0,0 +1,689 @@ +/** + * View-only-zone seat display sprite assertions. These lock down the core + * invariant of the view-only-zone fix: in a view-only zone the user sees real + * occupancy/assignment icons (taken / assigned / yours), and `bookable: false` + * only demotes the *action* states (book → unavailable). Without these, a + * future refactor could revert viewer-zone seats to a uniform `unavailable` + * and no e2e would catch it — the bookings/permission specs only assert + * action-button visibility, never the rendered sprite. + * + * Sprite cell names come from js/views/modules/seat.js `spriteFor`: + * taken, assigned, unavailable, yours, available, rebook, availableAssigned + * Asserted via the active `` href on `#sprite-` (OfficeMap keeps one + * `` per distinct cell name and toggles `display` to switch — see + * expectSprite below; same pattern as e2e/tests/officemap/officemap.spec.ts). + * + * Setup rule: seed all DB state (bookings, assignments) BEFORE navigating to the + * plan — getSeats runs on mount and the seat data is fixed then; selecting a + * date only recomputes state from already-loaded data, it does not re-fetch. + */ +import { test, expect } from '../../fixtures'; +import { logIn } from '../../helpers/auth'; +import { USER1, USER2, USER3 } from '../../helpers/users'; +import { querySql } from '../../helpers/db'; +import { + futureDayTs, + selectOnlyDates, + waitForSeatsLoaded, + clickActionBtn, + activateBookFor, +} from '../../helpers/booking'; +import { + ZONE_TYPE_ENABLED, + ZONE_TYPE_PUBLIC_VIEW, + ZONE_ROLE_USER, + ZONE_ROLE_ADMIN, + createPlan, + createZone, + addSeats, + assignZoneRole, + assignSeat, + insertBooking, +} from '../../helpers/zone-setup'; + +/** Assert the seat's ACTIVE sprite href ends in #cell-. + * OfficeMap keeps one per distinct cell name and toggles `display` to + * switch sprites (it never mutates href or removes old uses, to avoid a + * re-resolve blink). So a seat that started `unavailable` then became `taken` + * has two children — only the active one has `display !== 'none'`. + * Auto-retries via expect.poll until the right sprite is active. */ +async function expectSprite(page: any, seatId: number, cell: string) { + await expect.poll(async () => { + return await page.evaluate((id) => { + const glyph = document.querySelector(`#sprite-${id} .OMSeatGlyph`); + if (!glyph) return null; + for (const u of glyph.querySelectorAll('use')) { + if (u.style.display !== 'none') return u.getAttribute('href'); + } + return null; + }, seatId); + }).toMatch(RegExp(`#${cell}$`)); +} + +/** Click a seat and assert the action modal does NOT open. For a pure viewer + * (isZoneViewer) the modal is removed from the DOM entirely; for a mixed-plan + * user it exists but must stay closed. Both are correct "no action" outcomes. */ +async function clickExpectsNoModal(page: any, seatId: number) { + await page.locator(`#sprite-${seatId}`).click(); + await page.waitForTimeout(300); + const modal = page.locator('#action_modal'); + if (await modal.count() > 0) { + await expect(modal).not.toHaveClass(/open/); + } +} + +/** A 09:00–17:00 slot for the default-slider day (tomorrow). */ +const DAY = futureDayTs(1); +function slot() { + return { fromTS: DAY + 9 * 3600, toTS: DAY + 17 * 3600 }; +} + +/** Disable a seat directly (no helper in zone-setup yet). */ +async function disableSeat(seatId: number) { + await querySql('UPDATE seat SET enabled = false WHERE id = $1', [seatId]); +} + +/** Set the plan time-slider range via the HH:MM edit boxes (fires `change`, + * which the app maps to a noUiSlider.set). Used to test pure-shrink vs extend + * selections against a seeded own booking. */ +async function setSliderTimes(page: any, lo: string, hi: string) { + const minInput = page.locator('#timeslider-min'); + const maxInput = page.locator('#timeslider-max'); + await minInput.fill(''); + await minInput.fill(lo); + await minInput.dispatchEvent('change'); + await maxInput.fill(''); + await maxInput.fill(hi); + await maxInput.dispatchEvent('change'); + await page.waitForTimeout(200); +} + +// --------------------------------------------------------------------------- +// Viewer zone (PUBLIC_VIEW): informational icons survive, action icon demoted +// --------------------------------------------------------------------------- + +test.describe('view-only zone: informational icons survive, action icon demoted', () => { + /** Plan with one PUBLIC_VIEW zone + 4 seats; user3 is an implicit viewer. */ + async function setupViewZone() { + const pid = await createPlan('Sprite View Plan', 1); + const zid = await createZone('Sprite View Zone', ZONE_TYPE_PUBLIC_VIEW); + const [sTaken, sAssigned, sEveryone, sFree] = await addSeats(pid, zid, [ + 'SV.taken', 'SV.assigned', 'SV.everyone', 'SV.free', + ]); + return { pid, zid, sTaken, sAssigned, sEveryone, sFree }; + } + + test('1. seat booked by another user → taken; click opens no modal', async ({ page }) => { + const { pid, sTaken } = await setupViewZone(); + const { fromTS, toTS } = slot(); + await insertBooking(USER1.login, sTaken, fromTS, toTS); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, sTaken, 'cell-taken'); + await clickExpectsNoModal(page, sTaken); + }); + + test('2. seat assigned to another user, free → assigned', async ({ page }) => { + const { pid, sAssigned } = await setupViewZone(); + await assignSeat(sAssigned, USER1.login, null); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, sAssigned, 'cell-assigned'); + }); + + test('3. seat with everyone-assignment only, free → unavailable', async ({ page }) => { + const { pid, sEveryone } = await setupViewZone(); + await assignSeat(sEveryone, null, null); // everyone-only, no named assignee + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // Everyone-only carries no person info for a non-booker → falls through to + // CAN_BOOK → demoted to VIEW_ONLY → unavailable. + await expectSprite(page, sEveryone, 'cell-unavailable'); + }); + + test('4. free unassigned seat → unavailable; click opens no modal', async ({ page }) => { + const { pid, sFree } = await setupViewZone(); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, sFree, 'cell-unavailable'); + await clickExpectsNoModal(page, sFree); + }); +}); + +// --------------------------------------------------------------------------- +// Own booking in a view-only zone: release still works via the modal +// --------------------------------------------------------------------------- + +test.describe('own booking in a view-only zone', () => { + test('5. own booking exact match → yours; delete via modal works', async ({ page }) => { + // Mixed plan so the user is NOT a pure viewer (isZoneViewer false → the + // action modal exists in the DOM). user3 is USER in the enabled zone and an + // implicit viewer in the PUBLIC_VIEW zone; their own booking is on the + // view-only seat. CAN_DELETE_EXACT is informational (not demoted by + // !bookable) → sprite `yours`, and release is offered (apply()'s remove + // bypasses the zone-admin check for own bookings). + const pid = await createPlan('Sprite Own Plan', 1); + const enabledZid = await createZone('SO Enabled', ZONE_TYPE_ENABLED); + const viewZid = await createZone('SO View', ZONE_TYPE_PUBLIC_VIEW); + const [enabledSeat] = await addSeats(pid, enabledZid, ['SO.enabled']); + const [viewSeat] = await addSeats(pid, viewZid, ['SO.view']); + await assignZoneRole(enabledZid, 'user3', ZONE_ROLE_USER); + + const { fromTS, toTS } = slot(); + await insertBooking(USER3.login, viewSeat, fromTS, toTS); + + await logIn(page, USER3); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, viewSeat, 'cell-yours'); + + await page.locator(`#sprite-${viewSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'delete'); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER3.login, viewSeat], + ); + expect(r.rows[0].cnt).toBe(0); + // enabledSeat stays bookable (untouched) — sanity that the mixed plan held. + await expectSprite(page, enabledSeat, 'cell-available'); + }); +}); + +// --------------------------------------------------------------------------- +// Pure viewer (isZoneViewer): the action modal is kept in the DOM so an own +// booking can be released from the plan map, but non-actionable seats open no +// modal. (Counterpart to test 5, which uses a mixed plan so isZoneViewer is +// false; here the user has ONLY view-only access.) +// --------------------------------------------------------------------------- + +test.describe('pure viewer releasing own booking from the plan map', () => { + test('9. pure viewer: own booking -> yours + release works; free/TAKEN open no modal', async ({ page }) => { + const pid = await createPlan('Sprite Pure Viewer Plan', 1); + const zid = await createZone('SPV Zone', ZONE_TYPE_PUBLIC_VIEW); + const [ownSeat, freeSeat, takenSeat] = await addSeats(pid, zid, ['SPV.own', 'SPV.free', 'SPV.taken']); + const { fromTS, toTS } = slot(); + await insertBooking(USER3.login, ownSeat, fromTS, toTS); + await insertBooking(USER1.login, takenSeat, fromTS, toTS); + + await logIn(page, USER3); // user3 has no explicit role -> pure viewer + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // Own booking -> yours; the other two are non-actionable for a viewer. + await expectSprite(page, ownSeat, 'cell-yours'); + await expectSprite(page, freeSeat, 'cell-unavailable'); + await expectSprite(page, takenSeat, 'cell-taken'); + + // Non-actionable seats must NOT open the bottom-sheet panel. + await clickExpectsNoModal(page, freeSeat); + await clickExpectsNoModal(page, takenSeat); + + // Own booking -> Release modal -> booking gone (apply() remove bypasses the + // zone-admin check for own bookings). + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'delete'); + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER3.login, ownSeat], + ); + expect(r.rows[0].cnt).toBe(0); + }); + + test('10. pure viewer: own booking with non-matching time stays blue (yoursChange), release works', async ({ page }) => { + // user3's booking is seeded at 10:00-16:00; the default slider selects + // 09:00-17:00, so the booking overlaps but is NOT an exact match. CAN_CHANGE + // is not demoted for !bookable seats: the seat shows the blue "yoursChange" + // icon. The selection extends beyond the booking on both sides, so it is + // NOT a pure shrink → Update is not offered, only Release. + const pid = await createPlan('Sprite NonExact Plan', 1); + const zid = await createZone('SNE Zone', ZONE_TYPE_PUBLIC_VIEW); + const [ownSeat] = await addSeats(pid, zid, ['SNE.1']); + await insertBooking(USER3.login, ownSeat, DAY + 10 * 3600, DAY + 16 * 3600); + + await logIn(page, USER3); // pure viewer + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); // default slider 09:00-17:00 + await page.waitForTimeout(400); + + // Non-exact own booking -> blue "yoursChange" (NOT grey "taken", not plain + // "yours" — the arrows signal the booking can change, and a shrink would). + await expectSprite(page, ownSeat, 'cell-yoursChange'); + + // Update is NOT offered (selection extends beyond the booking -> not a pure + // shrink); Release still works end-to-end. + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); + await clickActionBtn(page, 'delete'); + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER3.login, ownSeat], + ); + expect(r.rows[0].cnt).toBe(0); + }); + + test('12. pure viewer: shrink own booking through the modal (Update offered + works; extend offers Release only)', async ({ page }) => { + // A pure shrink of an own booking is always allowed, even in a view-only + // zone. user3's booking is seeded at 09:00-17:00; narrowing the slider to + // 10:00-16:00 (fully contained) -> cell-yoursChange, modal offers Release + // AND Update, Update succeeds (apply() pure-shrink bypass) and the booking + // becomes 10:00-16:00. Then widening the slider back to 09:00-17:00 + // (extends beyond the now-10:00-16:00 booking) -> Release only. + const pid = await createPlan('Sprite Shrink Plan', 1); + const zid = await createZone('SS Zone', ZONE_TYPE_PUBLIC_VIEW); + const [ownSeat] = await addSeats(pid, zid, ['SS.1']); + await insertBooking(USER3.login, ownSeat, DAY + 9 * 3600, DAY + 17 * 3600); + + await logIn(page, USER3); // pure viewer + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await setSliderTimes(page, '10:00', '16:00'); + await page.waitForTimeout(400); + + // Contained selection -> yoursChange, and Update is offered (pure shrink). + await expectSprite(page, ownSeat, 'cell-yoursChange'); + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).toBeVisible(); + await clickActionBtn(page, 'update'); + + // Booking replaced: 10:00-16:00 now. + const after = await querySql( + 'SELECT fromts::int AS f, tots::int AS t FROM book WHERE login = $1 AND sid = $2', + [USER3.login, ownSeat], + ); + expect(after.rows.length).toBe(1); + expect(after.rows[0].f).toBe(DAY + 10 * 3600); + expect(after.rows[0].t).toBe(DAY + 16 * 3600); + + // Now widen the slider beyond the booking -> not a shrink -> Release only. + await setSliderTimes(page, '09:00', '17:00'); + await page.waitForTimeout(400); + await expectSprite(page, ownSeat, 'cell-yoursChange'); + await page.locator(`#sprite-${ownSeat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="update"]')).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Risk #1 guard: conflict map across a mixed ENABLED + PUBLIC_VIEW zone_group +// --------------------------------------------------------------------------- + +test.describe('mixed ENABLED + PUBLIC_VIEW in one zone_group (risk #1 guard)', () => { + test('6. viewer-zone taken stays taken; enabled-zone free shows rebook', async ({ page }) => { + const pid = await createPlan('Sprite Mixed Plan', 1); + const enabledZid = await createZone('SM Enabled', ZONE_TYPE_ENABLED, 'smGrp'); + const viewZid = await createZone('SM View', ZONE_TYPE_PUBLIC_VIEW, 'smGrp'); + const [enabledOwn, enabledFree] = await addSeats(pid, enabledZid, ['SM.own', 'SM.free']); + const [viewTaken] = await addSeats(pid, viewZid, ['SM.taken']); + await assignZoneRole(enabledZid, 'user2', ZONE_ROLE_USER); + + const { fromTS, toTS } = slot(); + // user2 holds enabledOwn (exact match); user1 holds viewTaken (foreign). + await insertBooking(USER2.login, enabledOwn, fromTS, toTS); + await insertBooking(USER1.login, viewTaken, fromTS, toTS); + + await logIn(page, USER2); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // The viewer-zone TAKEN seat must stay `taken` — not be demoted to + // unavailable, and must NOT suppress the same-group rebook in the enabled + // zone (isMine=false for the foreign booking → no conflict-map entry). + await expectSprite(page, viewTaken, 'cell-taken'); + // user2's own same-group booking makes enabledFree a rebook. + await expectSprite(page, enabledFree, 'cell-rebook'); + // Own exact booking → yours. + await expectSprite(page, enabledOwn, 'cell-yours'); + }); +}); + +// --------------------------------------------------------------------------- +// Risk #5 guard: assigned-to-me in a bookable ENABLED zone stays availableAssigned +// --------------------------------------------------------------------------- + +test.describe('assigned-to-me in a bookable ENABLED zone (risk #5 guard)', () => { + test('7. seat assigned to me, within window, free → availableAssigned', async ({ page }) => { + const pid = await createPlan('Sprite AssignMe Plan', 1); + const zid = await createZone('SAM Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SAM.1']); + await assignZoneRole(zid, 'user2', ZONE_ROLE_USER); + // Assigned to user2, unlimited days_in_advance → within window → falls + // through to CAN_BOOK (not ASSIGNED), with assignedToMe → availableAssigned. + await assignSeat(seat, USER2.login, null); + + await logIn(page, USER2); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + await expectSprite(page, seat, 'cell-availableAssigned'); + }); +}); + +// --------------------------------------------------------------------------- +// Book-for override of an assignment: admin books FOR a target onto a seat +// assigned to someone else (apply() skips 106/110 under is_book_for). The +// seat is bookable despite the assignment, so it renders the plain green +// "available" icon (third-party assignment) or blue "availableAssigned" +// (assigned to the target, beyond its window) — no special override glyph. +// --------------------------------------------------------------------------- + +test.describe('book-for override of an assignment', () => { + test('8. seat assigned to a third person, book-for target is a member → available + book works', async ({ page }) => { + const pid = await createPlan('Sprite Override Plan', 1); + const zid = await createZone('SO Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SO.1']); + // user1 administers the zone; user2 (the book-for target) is a member; + // the seat is assigned to user3 (a third person, not the target). + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await assignSeat(seat, USER3.login, null); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + // Default self-view (admin): the seat is bookable at the zone level but + // self-book is blocked by 106 → grey ASSIGNED. + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assigned'); + + // Switch to book-for user2: the admin may now override the assignment → + // plain green `available` (third-party assignment), and the book action + // books for user2. + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-available'); + + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await clickActionBtn(page, 'book'); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seat], + ); + expect(r.rows[0].cnt).toBe(1); + }); + + test('8b. seat assigned to the target beyond its window, book-for → availableAssigned', async ({ page }) => { + // The seat is assigned to user2 (the book-for target) with days_in_advance + // = 0, so the default tomorrow slot is beyond the target's window. Self-view + // (admin user1) is not the assignee → grey ASSIGNED; under book-for user2 + // the window is overridden and the seat falls through to CAN_BOOK with + // assignedToMe → blue `availableAssigned`. + const pid = await createPlan('Sprite Override Target Plan', 1); + const zid = await createZone('SOT Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SOT.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await assignSeat(seat, USER2.login, 0); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assigned'); + + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-availableAssigned'); + }); + + test('8c. disabled seat under book-for -> disabled icon; click offers book-for', async ({ page }) => { + // A seat the admin has disabled keeps the grey X icon even under book-for + // (visual cue the seat is off), but clicking it offers the book-for action + // (apply() skips 105 under is_book_for) — the admin books for the target + // without re-enabling the seat. Self-view offers no book (re-enable first). + const pid = await createPlan('Sprite Override Disabled Plan', 1); + const zid = await createZone('SOD Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SOD.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await disableSeat(seat); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // Self-view: disabled seat -> grey X, no book offered (seat-edit only). + await expectSprite(page, seat, 'cell-unavailable'); + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + await page.keyboard.press('Escape'); // close the action modal before opening book-for + await page.waitForTimeout(200); + + // Book-for user2: icon stays grey X (visual cue), but book is offered + works. + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-unavailable'); + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).toBeVisible(); + await clickActionBtn(page, 'book'); + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seat], + ); + expect(r.rows[0].cnt).toBe(1); + }); + + test('8e. book-for: target booking in a non-administered zone -> no doomed Release', async ({ page }) => { + // user1 admins zoneA; user2 (the book-for target) has a non-exact booking in + // zoneB (PUBLIC_VIEW), which user1 does NOT administer. Under book-for + // user2 that booking is user2's "own" (CAN_CHANGE -> yoursChange icon), but + // releasing it is a foreign release apply() would reject (403/102) — so no + // action is offered and no modal opens (the doomed Release button is + // suppressed, mirroring the CAN_REBOOK hasUnmanageableConflict guard). + const pid = await createPlan('Sprite DoomedRelease Plan', 1); + const zidA = await createZone('DR Admin', ZONE_TYPE_ENABLED); + const zidB = await createZone('DR View', ZONE_TYPE_PUBLIC_VIEW); + const [seatA] = await addSeats(pid, zidA, ['DR.A']); + const [seatB] = await addSeats(pid, zidB, ['DR.B']); + await assignZoneRole(zidA, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zidA, USER2.login, ZONE_ROLE_USER); + await insertBooking(USER2.login, seatB, DAY + 10 * 3600, DAY + 16 * 3600); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // Self-view: seatB is a foreign booking to user1 -> taken. + await expectSprite(page, seatB, 'cell-taken'); + + // Book-for user2: seatB is user2's own non-exact booking -> yoursChange, but + // in a zone user1 doesn't administer -> no Release offered (doomed). + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seatB, 'cell-yoursChange'); + await page.locator(`#sprite-${seatB}`).click(); + await page.waitForTimeout(300); + const modal = page.locator('#action_modal'); + if (await modal.count() > 0) { + await expect(modal).not.toHaveClass(/open/); + } + // The administered zoneA seat still behaves normally under book-for. + await expectSprite(page, seatA, 'cell-available'); + }); + + test('8f. disabled seat under book-for with a target conflict -> update (relocate target)', async ({ page }) => { + // Target user2 already holds enabled seat E in zoneA (same zone_group as the + // disabled seat D). Under book-for user2, D runs the normal pipeline -> + // CAN_BOOK, then _updateView promotes it to CAN_REBOOK (the target's booking + // on E is a same-group conflict). The icon is forced to grey X (disabled + // cue), but clicking offers Update (book D + release E) — NOT just book, + // which the DB book_overlap trigger would reject. Update succeeds and user2 + // is relocated from E to D. + const pid = await createPlan('Sprite DisabledRebook Plan', 1); + const zid = await createZone('DRA Zone', ZONE_TYPE_ENABLED, 'draGrp'); + const [seatD, seatE] = await addSeats(pid, zid, ['DRA.D', 'DRA.E']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await disableSeat(seatD); + const { fromTS, toTS } = slot(); + await insertBooking(USER2.login, seatE, fromTS, toTS); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await activateBookFor(page, `${USER2.name} [${USER2.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // D shows the disabled grey X (forced), not green rebook. + await expectSprite(page, seatD, 'cell-unavailable'); + + // Clicking D offers Update (relocate user2 from E to D), not book. + await page.locator(`#sprite-${seatD}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="update"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + await clickActionBtn(page, 'update'); + + const onD = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seatD], + ); + const onE = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seatE], + ); + expect(onD.rows[0].cnt).toBe(1); + expect(onE.rows[0].cnt).toBe(0); + }); + + test('8d. admin selects themselves in book-for -> normal mode (assigned-to-other stays grey, no book offered)', async ({ page }) => { + // Selecting your own login in the book-for picker is the exit from + // book-for mode: no book.login is sent, so the admin is a regular user + // for that selection -- assignments/windows apply, no green override. A + // seat assigned to a third person stays grey `assigned` (not green), and + // the book action is not offered (self-book there would be 106). + const pid = await createPlan('Sprite SelfInBookFor Plan', 1); + const zid = await createZone('SSB Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['SSB.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + await assignSeat(seat, USER3.login, null); + + await logIn(page, USER1); + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + // Default self-view: grey assigned (self-book blocked by 106). + await expectSprite(page, seat, 'cell-assigned'); + + // Select the admin's OWN login in the book-for picker -> still normal mode. + await activateBookFor(page, `${USER1.name} [${USER1.login}]`); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await expectSprite(page, seat, 'cell-assigned'); + + // No book action offered (an actual book-for target would show green + + // book; self-selection does not). + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Zone admin releasing another user's booking from the plan map. The seat is +// TAKEN (foreign booking); a zone admin who administers the seat's zone may +// release it directly (apply()'s remove requires per-seat zone-admin for +// foreign bookings). Non-admins get no action (TAKEN stays informational). +// --------------------------------------------------------------------------- + +test.describe('zone admin releasing another user booking from the plan map', () => { + test('11. admin clicks a foreign-booked seat -> Release works; non-admin gets no modal', async ({ page }) => { + const pid = await createPlan('Sprite ForeignRelease Plan', 1); + const zid = await createZone('FR Zone', ZONE_TYPE_ENABLED); + const [seat] = await addSeats(pid, zid, ['FR.1']); + await assignZoneRole(zid, USER1.login, ZONE_ROLE_ADMIN); + await assignZoneRole(zid, USER2.login, ZONE_ROLE_USER); + const { fromTS, toTS } = slot(); + await insertBooking(USER2.login, seat, fromTS, toTS); // foreign to user1 + + await logIn(page, USER1); // admin of the zone + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + + // The seat is TAKEN (foreign booking). + await expectSprite(page, seat, 'cell-taken'); + + // Admin -> Release modal (delete offered, book not). + await page.locator(`#sprite-${seat}`).click(); + await expect(page.locator('#action_modal')).toHaveClass(/open/); + await expect(page.locator('.plan_action_btn[data-action="delete"]')).toBeVisible(); + await expect(page.locator('.plan_action_btn[data-action="book"]')).not.toBeVisible(); + // The to-be-released row names the foreign owner. + await expect(page.locator('#action_modal_msg2')).toContainText(USER2.name); + await clickActionBtn(page, 'delete'); + + const r = await querySql( + 'SELECT COUNT(*)::int AS cnt FROM book WHERE login = $1 AND sid = $2', + [USER2.login, seat], + ); + expect(r.rows[0].cnt).toBe(0); + + // A non-admin clicking a foreign-booked seat gets no modal (TAKEN is + // informational for non-admins). + await logIn(page, USER2); // USER in the zone, but the booking is gone + await page.goto(`/plan/${pid}`); + await waitForSeatsLoaded(page); + await insertBooking(USER1.login, seat, fromTS, toTS); // now booked by user1 (foreign to user2) + await page.reload(); + await waitForSeatsLoaded(page); + await selectOnlyDates(page, [DAY]); + await page.waitForTimeout(400); + await page.locator(`#sprite-${seat}`).click(); + await page.waitForTimeout(300); + await expect(page.locator('#action_modal')).not.toHaveClass(/open/); + }); +}); \ No newline at end of file diff --git a/e2e/tests/bookings-page/list.spec.ts b/e2e/tests/bookings-page/list.spec.ts index 1df153b..b7c7c13 100644 --- a/e2e/tests/bookings-page/list.spec.ts +++ b/e2e/tests/bookings-page/list.spec.ts @@ -3,7 +3,7 @@ */ import { test, expect } from '../../fixtures'; import { logIn } from '../../helpers/auth'; -import { USER1, USER2 } from '../../helpers/users'; +import { USER1, USER2, USER3 } from '../../helpers/users'; import { querySql } from '../../helpers/db'; import { futureDayTs, getZoneSeats } from '../../helpers/booking'; import { insertBooking, clearDefaultUserFilter } from '../../helpers/bookings-page'; @@ -55,7 +55,9 @@ test.describe('bookings list visibility', () => { await expect(page.locator('.tabulator-row')).toHaveCount(2); }); - test('bookings in zones the user cannot access are NOT shown', async ({ page }) => { + test("another user's bookings in zones the user cannot access are NOT shown", async ({ page }) => { + // user2 has no access to zone 2; user1's booking there is foreign to + // user2 → stays hidden (the own-booking disjunct only admits own rows). const zone2Seats = await getZoneSeats(2); await insertBooking('user1', zone2Seats[0].id); @@ -65,6 +67,40 @@ test.describe('bookings list visibility', () => { await expect(page.locator('.tabulator-row')).toHaveCount(0); }); + test('own booking in a zone the user has no access to IS shown and deletable', async ({ page }) => { + // user3 has no zone_assign at all → no access to enabled zone 2. But they + // hold a booking there (e.g. access was revoked after booking, or an + // admin booked them in then removed their role). The plan map can't reach + // a seat in a zone they can't open, so the bookings table is the only UI + // path to release it: the row must be visible and the delete icon shown. + const zone2Seats = await getZoneSeats(2); + const bid = await insertBooking('user3', zone2Seats[0].id); + + await logIn(page, USER3); + await page.goto('/bookings'); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.tabulator-row')).toHaveCount(1); + await expect(page.locator('.tabulator-row').first()).toContainText(zone2Seats[0].name); + await expect( + page.locator('.tabulator-row').first().locator('.material-icons.warp-icon-danger'), + ).toBeVisible(); + + // And the release actually works end-to-end. + await page.locator('.tabulator-row').first().locator('.material-icons.warp-icon-danger').click(); + const modal = page.locator('.modal', { hasText: 'Are you sure to release this booking?' }); + await expect(modal).toBeVisible(); + await Promise.all([ + page.waitForResponse(r => r.url().includes('/xhr/plan/apply') && r.status() === 200), + modal.locator('button.modal-close', { hasText: /yes/i }).click(), + ]); + await page.waitForLoadState('networkidle'); + + await expect(page.locator('.tabulator-row')).toHaveCount(0); + const result = await querySql('SELECT COUNT(*)::int AS cnt FROM book WHERE id = $1', [bid]); + expect(result.rows[0].cnt).toBe(0); + }); + test('delete icon is present for own bookings (rw=true)', async ({ page }) => { const [seat] = await getZoneSeats(1); await insertBooking('user1', seat.id); diff --git a/e2e/tests/bookings-page/public-zones.spec.ts b/e2e/tests/bookings-page/public-zones.spec.ts index 1d9231f..2f2f691 100644 --- a/e2e/tests/bookings-page/public-zones.spec.ts +++ b/e2e/tests/bookings-page/public-zones.spec.ts @@ -55,7 +55,7 @@ test.describe('bookings page — public zone visibility', () => { ).toBeVisible(); }); - test('PUBLIC_VIEW: unassigned user sees own booking but cannot delete (rw=false)', async ({ page }) => { + test('PUBLIC_VIEW: unassigned user sees own booking and can delete (own booking always releasable)', async ({ page }) => { await setZoneType(1, ZONE_TYPE_PUBLIC_VIEW); const [seat] = await getZoneSeats(1); @@ -67,10 +67,12 @@ test.describe('bookings page — public zone visibility', () => { await expect(page.locator('.tabulator-row')).toHaveCount(1); await expect(page.locator('.tabulator-row').first()).toContainText(seat.name); - // PUBLIC_VIEW grants VIEWER only → rw=false → no delete icon + // PUBLIC_VIEW grants VIEWER only, but a user can always release their + // OWN booking (apply()'s remove bypasses the zone-admin check for own + // bookings) → rw=true → delete icon visible. await expect( page.locator('.tabulator-row').first().locator('.material-icons.warp-icon-danger'), - ).toHaveCount(0); + ).toBeVisible(); }); test('PUBLIC_BOOK: unassigned user sees another user booking but cannot delete (rw=false)', async ({ page }) => { diff --git a/e2e/tests/zone-admin/assignment.spec.ts b/e2e/tests/zone-admin/assignment.spec.ts index bcc8b01..1775b24 100644 --- a/e2e/tests/zone-admin/assignment.spec.ts +++ b/e2e/tests/zone-admin/assignment.spec.ts @@ -195,7 +195,7 @@ test.describe('edit seat modal UI', () => { await addInput.pressSequentially('Bar', { delay: 50 }); // 2.x renders every autocomplete's dropdown items on init (not only when // typed into), and the dropdown is moved to document.body, so a bare - // `ul.autocomplete-content li` also matches the BookAs dropdown on this + // `ul.autocomplete-content li` also matches the BookFor dropdown on this // page. Scope to this input's own dropdown via its data-target id. const dropdownId = await addInput.getAttribute('data-target'); const dropdownItem = page.locator(`ul#${dropdownId} li`, { hasText: 'Bar [user2]' }); diff --git a/e2e/tests/zone-admin/book-as.spec.ts b/e2e/tests/zone-admin/book-for.spec.ts similarity index 85% rename from e2e/tests/zone-admin/book-as.spec.ts rename to e2e/tests/zone-admin/book-for.spec.ts index 73cd231..cbbd997 100644 --- a/e2e/tests/zone-admin/book-as.spec.ts +++ b/e2e/tests/zone-admin/book-for.spec.ts @@ -8,10 +8,11 @@ import { clickZoneSeat, waitForSeatsLoaded, apiApply, + activateBookFor, } from '../../helpers/booking'; import { pickFirstDate } from '../../helpers/zone-admin'; -test.describe('booking as another user', () => { +test.describe('booking for another user', () => { test('zone admin can book a seat for another user via API', async ({ page }) => { await logIn(page, USER1); @@ -44,7 +45,7 @@ test.describe('booking as another user', () => { expect(result.rows[0].cnt).toBe(0); }); - test('zone admin can book a seat via "Book as" UI (autocomplete)', async ({ page }) => { + test('zone admin can book a seat via "Book for" UI (autocomplete)', async ({ page }) => { await logIn(page, USER1); await page.goto('/plan/1'); await waitForSeatsLoaded(page); @@ -52,13 +53,7 @@ test.describe('booking as another user', () => { await pickFirstDate(page); await page.waitForTimeout(400); - const bookAsInput = page.locator('#book-as'); - await bookAsInput.click(); - await bookAsInput.pressSequentially('Bar', { delay: 50 }); - const dropdownItem = page.locator('ul.autocomplete-content li', { hasText: 'Bar [user2]' }); - await expect(dropdownItem).toBeVisible({ timeout: 5000 }); - await dropdownItem.click(); - await page.waitForTimeout(200); + await activateBookFor(page, 'Bar [user2]'); const [seat] = await getZoneSeats(1); await clickZoneSeat(page, seat); diff --git a/js/app/main.js b/js/app/main.js index affa6c9..44b0148 100644 --- a/js/app/main.js +++ b/js/app/main.js @@ -83,7 +83,7 @@ async function boot() { // Opt every WARP form field into Materialize 2.x's built-in `.outlined` // text-field variant (bordered box). Scoped to .warp-fields containers so - // the nav search and the zone "book-as" underline field are left untouched; + // the nav search and the zone "book-for" underline field are left untouched; // chips are excluded (they are a multi-value container, not a text field). document.querySelectorAll('.warp-fields .input-field:not(.chips)').forEach(function (el) { el.classList.add('outlined'); diff --git a/js/app/router.js b/js/app/router.js index b1438ad..881cf86 100644 --- a/js/app/router.js +++ b/js/app/router.js @@ -72,7 +72,7 @@ export async function transition(pathname, search) { // 2. Await the previous view's unmount, then clear the mount point. // Null the handle BEFORE awaiting it: two rapid navigations could both // observe a non-null currentUnmount and double-invoke the same unmount - // (double table.destroy()/om.destroy()/BookAs.reset()), where the second + // (double table.destroy()/om.destroy()/BookFor.reset()), where the second // throw is swallowed by the catch below and any teardown after it is // silently skipped (leaked observers/sliders across the navigation). var prevUnmount = currentUnmount; @@ -151,7 +151,7 @@ export async function transition(pathname, search) { // a slow getContext): the just-returned unmount is NOT assigned to // currentUnmount (the seq check below would skip it), so without // invoking it here B's OfficeMap listeners/theme observer leak - // permanently and its PlanUserData/BookAs singletons stay init'd, + // permanently and its PlanUserData/BookFor singletons stay init'd, // then C's mount throws "already initialized" -> "Page not found". // Tear B down now so C mounts clean. currentController = null; diff --git a/js/base/style.css b/js/base/style.css index a791f1e..8b488a5 100644 --- a/js/base/style.css +++ b/js/base/style.css @@ -482,16 +482,16 @@ nav .dropdown-content li > span { margin: 0 32px; } -.book-as_container { +.book-for_container { padding-top: 4px; } -/* The "Book as" autocomplete in the zone sidepanel. Keep it as a clean +/* The "Book for" autocomplete in the zone sidepanel. Keep it as a clean underline-style input (no filled box) but give it enough height so the value and label don't overlap or look cramped in the narrow panel. */ -.book-as_container.input-field { +.book-for_container.input-field { margin-top: 0; } -.book-as_container .book-as_input { +.book-for_container .book-for_input { background-color: transparent; border: none; border-bottom: 1px solid var(--warp-grey-muted); @@ -503,8 +503,8 @@ nav .dropdown-content li > span { color: var(--warp-text); font-size: 1rem; } -.book-as_container.input-field > label, -.book-as_container.input-field > label.active { +.book-for_container.input-field > label, +.book-for_container.input-field > label.active { position: absolute; top: 2px; left: 0 !important; @@ -513,7 +513,7 @@ nav .dropdown-content li > span { font-size: 0.75rem; color: var(--warp-grey-text); } -.book-as_container .book-as_input:focus { +.book-for_container .book-for_input:focus { border-bottom-color: var(--warp-primary); outline: none; } @@ -1161,14 +1161,14 @@ nav .dropdown-content li > span { .plan_sidepanel_topcontainer { display: flex; flex-direction: row; - flex: 0 0 auto; /* book-as (+ close) pinned at the top */ - margin-bottom: var(--warp-space-3); /* gap between the book-as row and the calendar widget (point 4) */ + flex: 0 0 auto; /* book-for (+ close) pinned at the top */ + margin-bottom: var(--warp-space-3); /* gap between the book-for row and the calendar widget (point 4) */ } .plan_datetime_container { display: flex; flex-direction: column; /* slider on top, calendar below (point 2) */ - flex: 1 1 auto; /* fill the panel below the book-as row */ + flex: 1 1 auto; /* fill the panel below the book-for row */ min-height: 0; /* so the calendar grid can scroll instead of growing the panel */ gap: var(--warp-space-3); /* padding between the slider and the calendar */ } diff --git a/js/views/html/plan.html b/js/views/html/plan.html index 6288360..4b24edf 100644 --- a/js/views/html/plan.html +++ b/js/views/html/plan.html @@ -1,6 +1,9 @@ - - + + @@ -71,7 +74,7 @@
- The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated. + The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.
@@ -83,7 +86,7 @@
- The seat is not available for booking: no dates selected, disabled, or view-only zone. + The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone. @@ -100,9 +103,9 @@
-
- - +
+ +
diff --git a/js/views/modules/bookas.js b/js/views/modules/bookfor.js similarity index 74% rename from js/views/modules/bookas.js rename to js/views/modules/bookfor.js index bb1a43d..cf74094 100644 --- a/js/views/modules/bookas.js +++ b/js/views/modules/bookfor.js @@ -2,10 +2,10 @@ import PlanUserData from "./planuserdata"; -export default function BookAs() { +export default function BookFor() { - if (typeof(BookAs.instance) !== 'undefined') - throw Error('BookAs is a singleton'); + if (typeof(BookFor.instance) !== 'undefined') + throw Error('BookFor is a singleton'); this.listeners = { init: new Set(), @@ -14,18 +14,18 @@ export default function BookAs() { this.changed = false; - BookAs.instance = this; + BookFor.instance = this; } -BookAs.getInstance = function() { +BookFor.getInstance = function() { - if (typeof(BookAs.instance) === 'undefined') - return new BookAs(); + if (typeof(BookFor.instance) === 'undefined') + return new BookFor(); - return BookAs.instance; + return BookFor.instance; } -BookAs.prototype.on = function (type,listener) { +BookFor.prototype.on = function (type,listener) { if (type in this.listeners && typeof(listener) === 'function') { this.listeners[type].add(listener); @@ -39,10 +39,10 @@ BookAs.prototype.on = function (type,listener) { } // if skipMine this function will return null in case my login is selected -BookAs.prototype.getSelectedLogin = function(skipMine) { +BookFor.prototype.getSelectedLogin = function(skipMine) { if (!('selectedLogin' in this)) - throw Error("BookAs not initialized") + throw Error("BookFor not initialized") if (skipMine && this.zoneUserData.whoami() === this.selectedLogin) return null; @@ -50,10 +50,10 @@ BookAs.prototype.getSelectedLogin = function(skipMine) { return this.selectedLogin; } -BookAs.prototype._setSelectedLogin = function(login) { +BookFor.prototype._setSelectedLogin = function(login) { if (!('selectedLogin' in this)) - throw Error("BookAs not initialized") + throw Error("BookFor not initialized") if (!(login in this.zoneUserData.data)) login = this.zoneUserData.whoami(); @@ -64,7 +64,7 @@ BookAs.prototype._setSelectedLogin = function(login) { } } -BookAs.prototype._callChangeListeners = function() { +BookFor.prototype._callChangeListeners = function() { if (this.changed) { for (let l of this.listeners['change']) @@ -74,7 +74,7 @@ BookAs.prototype._callChangeListeners = function() { } -BookAs.prototype._onBlur = function(e) { +BookFor.prototype._onBlur = function(e) { let target = e.target; @@ -88,7 +88,7 @@ BookAs.prototype._onBlur = function(e) { } } -BookAs.prototype._onAutocomplete = function(el,sel) { +BookFor.prototype._onAutocomplete = function(el,sel) { var login = PlanUserData.makeUserStrRev(sel); this._setSelectedLogin(login); @@ -96,7 +96,7 @@ BookAs.prototype._onAutocomplete = function(el,sel) { this._callChangeListeners(); } -BookAs.prototype._onKeyUp = function(e) { +BookFor.prototype._onKeyUp = function(e) { let target = e.target; @@ -111,25 +111,25 @@ BookAs.prototype._onKeyUp = function(e) { } } -BookAs.prototype._init = function(zoneUserData) { +BookFor.prototype._init = function(zoneUserData) { if ('selectedLogin' in this) - throw Error("BookAs can be initialized only once") + throw Error("BookFor can be initialized only once") this.zoneUserData = zoneUserData; this.selectedLogin = this.zoneUserData.whoami(); - this.bookAsData = {}; + this.bookForData = {}; for (let d of this.zoneUserData.formatedIterator()) { - this.bookAsData[ d] = null; + this.bookForData[ d] = null; } this.autocompleteInstances = []; - var bookAsElements = document.getElementsByClassName('book-as_input'); - for (let el of bookAsElements) { + var bookForElements = document.getElementsByClassName('book-for_input'); + for (let el of bookForElements) { this.autocompleteInstances.push( M.Autocomplete.init(el, { - data: this.bookAsData, + data: this.bookForData, dropdownOptions: { constrainWidth: false, container: document.body @@ -139,7 +139,7 @@ BookAs.prototype._init = function(zoneUserData) { })); } - for (let el of bookAsElements) { + for (let el of bookForElements) { this._onBlur({target: el}); // show selectedLoginStr el.addEventListener('blur',this._onBlur.bind(this)); el.addEventListener('keyup',this._onKeyUp.bind(this)); @@ -153,14 +153,14 @@ BookAs.prototype._init = function(zoneUserData) { // Clears this singleton's per-mount state (the plan view's unmount() calls // this) so _init() can run again on the next admin plan mount instead of -// throwing "can be initialized only once". Its book-as_input DOM elements are +// throwing "can be initialized only once". Its book-for_input DOM elements are // gone already (router.js replaces #view-root wholesale) but their Materialize // Autocomplete dropdown panels are appended to document.body — outside // #view-root — so they'd leak across navigations without an explicit destroy. -// Also clears 'change'/'init' listeners: each plan mount's initBookAs() +// Also clears 'change'/'init' listeners: each plan mount's initBookFor() // registers its own; left stale, a second mount would fire the FIRST mount's // listener too, calling seatFactory.setLogin() on an abandoned factory. -BookAs.prototype.reset = function() { +BookFor.prototype.reset = function() { if (this.autocompleteInstances) { for (let inst of this.autocompleteInstances) { if (inst && typeof inst.destroy === 'function') inst.destroy(); @@ -168,7 +168,7 @@ BookAs.prototype.reset = function() { } delete this.selectedLogin; delete this.zoneUserData; - delete this.bookAsData; + delete this.bookForData; delete this.autocompleteInstances; this.changed = false; this.listeners['init'].clear(); @@ -176,5 +176,5 @@ BookAs.prototype.reset = function() { } PlanUserData.getInstance().on('load',function(zoneUserData) { - BookAs.getInstance()._init(zoneUserData); + BookFor.getInstance()._init(zoneUserData); }); diff --git a/js/views/modules/planuserdata.js b/js/views/modules/planuserdata.js index b44a90b..72388f9 100644 --- a/js/views/modules/planuserdata.js +++ b/js/views/modules/planuserdata.js @@ -61,8 +61,8 @@ PlanUserData.prototype.getData = function() { } // Clears loaded data so init() can reload it on a fresh SPA mount (the plan -// view's unmount() calls this). Deliberately keeps this.listeners — bookas.js -// wires BookAs to the 'load' event once, at module-import time, against this +// view's unmount() calls this). Deliberately keeps this.listeners — bookfor.js +// wires BookFor to the 'load' event once, at module-import time, against this // same singleton instance; replacing the instance instead of resetting it // would orphan that wiring. // Bumps the init generation so an in-flight PlanUserData.init() from a previous diff --git a/js/views/modules/seat.js b/js/views/modules/seat.js index 575bdba..9e1afb3 100644 --- a/js/views/modules/seat.js +++ b/js/views/modules/seat.js @@ -39,8 +39,7 @@ WarpSeat.SeatStates = { CAN_CHANGE: 6, // seat is already booked by this user, but can be changed (extended, reduced, deleted) CAN_DELETE: 7, // seat is already booked by this user, but cannot be changed CAN_DELETE_EXACT: 8, // seat is already booked by this user, cannot be changed and selected dated are exactly matching booking dates - VIEW_ONLY: 9, // seat is visible but cannot be booked — free (grey empty circle) - VIEW_ONLY_TAKEN: 10 // seat is visible but taken by someone else (grey circle with person) + VIEW_ONLY: 9 // bookable-shaped seat in a zone where this user may only view (free, unassigned) } WarpSeat.Sprites = { @@ -102,7 +101,7 @@ WarpSeatFactory.prototype.getLogin = function() { return this.login; } -// Switch the "acting" login (book-as). Callers must follow this with a full +// Switch the "acting" login (book-for). Callers must follow this with a full // downloadSeatData()/setSeatsData() refresh so every seat — accessible and // conflict — is rebuilt consistently for the new login. (The old partial // onlyOtherZone path is gone: it overwrote accessible seats that happened to @@ -146,6 +145,10 @@ WarpSeatFactory.prototype.setSeatsData = function(seatsData = {}) { // Full refresh — replace the zone-group map. this.zoneGroups = seatsData.zoneGroups || {}; + // zid -> true if the acting (real, not book-for target) user administers + // that zone. Used to gate seat-edit per-seat instead of the plan-wide + // isZoneAdmin flag. + this.zoneAdmin = seatsData.zoneAdmin || {}; var oldSeatsIds = new Set( Object.keys(this.instances)) @@ -218,6 +221,26 @@ WarpSeatFactory.prototype.setSeatsData = function(seatsData = {}) { return res; } +// True if updating forSeat would need to release a conflicting booking that +// lies in a zone the acting admin does not administer. Only relevant under +// book-for: apply() only requires zone-admin to release someone ELSE's +// booking (self-releases are never gated — see apply()'s seatsReqZoneAdmin, +// which excludes Book.login == flask.g.login). Releasing a foreign zone's +// booking would 403 (code 102) and roll back the whole book+remove request, +// so the frontend must not offer that "update" as if it would succeed. +WarpSeatFactory.prototype.hasUnmanageableConflict = function(forSeat) { + + if (this.login === window.warpGlobals.login) + return false; + + for (var c of this.getMyConflictingBookings(forSeat)) { + var conflictSeat = this.instances[c.sid]; + if (conflictSeat && !this.zoneAdmin[conflictSeat.zid]) + return true; + } + return false; +} + // Plan-wide: used by the auto-book FAB to detect when the current selection is // already exactly satisfied by an existing booking in any zone. WarpSeatFactory.prototype.isExactMatch = function() { @@ -302,6 +325,36 @@ WarpSeat.prototype.getZoneName = function() { return this.zoneName; } +// True if the acting user (the real logged-in admin, not a book-for target) +// administers this seat's zone — the correct per-seat gate for admin-only +// actions (seat-edit), as opposed to the plan-wide isZoneAdmin flag which is +// true if they administer ANY zone on the plan. +WarpSeat.prototype.isMyZoneAdmin = function() { + return !!this.factory.zoneAdmin[this.zid]; +} + +// True iff every selected date range is fully contained within one of the +// acting user's OWN bookings on this seat — a "pure shrink" selection: an +// update would only narrow an existing own booking, which is always allowed +// (apply()'s is_pure_shrink bypass, even in view-only / DISABLED zones). Uses +// window.warpGlobals.login (the real actor), not factory.login, so a book-for +// target's booking in a non-administered (!bookable) zone does NOT count — +// changing/releasing it would 403 (release confinement / seatsReqZoneAdmin). +WarpSeat.prototype.isSelectionShrinkOfMine = function() { + const me = window.warpGlobals.login; + for (const d of this.factory.selectedDates) { + let covered = false; + for (const b of this.book) { + if (b.login === me && b.fromTS <= d.fromTS && b.toTS >= d.toTS) { + covered = true; + break; + } + } + if (!covered) return false; + } + return this.factory.selectedDates.length > 0; +}; + WarpSeat.prototype.getSid = function() { return parseInt(this.sid); //TODO: convert this.sid to int in constructor @@ -334,6 +387,34 @@ WarpSeat.prototype.getBookings = function() { return res; } +// Another user's bookings on this seat overlapping the current selection +// (login != the acting user). Used by the plan-view action modal to let a +// zone admin release someone else's booking on a seat they administer +// (apply()'s remove requires per-seat zone-admin for foreign bookings). With +// raw=true returns an array of bid's; otherwise display objects +// {seat_name, zone_name, username, datetime1, datetime2}. +WarpSeat.prototype.getForeignBookings = function(raw) { + + var res = []; + + for (let i of this._bookingsIterator()) { + if (i.book.login == this.factory.login) + continue; + if (raw) { + res.push(i.book.bid); + } else { + res.push( Object.assign({ + seat_name: this.getName(), + zone_name: this.getZoneName(), + username: i.book.username, + }, + WarpSeatFactory._formatDatePair(i.book)) ); + } + } + + return res; +} + /** * Iterates over relevant (by given selectedDates) seat bookings */ @@ -363,6 +444,14 @@ WarpSeat.prototype._bookingsIterator = function*() { } } +// Book-for override of seat-level restrictions is in force for this seat: +// the actor is booking FOR someone else and `bookable` holds — which under +// book-for already means the actor administers the zone. Derived on demand +// (never stored) so it can't go stale across _updateState early returns. +WarpSeat.prototype._isBookForOverride = function() { + return this.bookable && this.factory.login !== window.warpGlobals.login; +} + WarpSeat.prototype._updateState = function() { if (!this.factory.selectedDates.length) { @@ -370,38 +459,33 @@ WarpSeat.prototype._updateState = function() { return this.state; } - if (!this.enabled) { + // Book-for override of seat-level restrictions (see apply() skipping + // 105/106/110 under is_book_for): a zone admin booking FOR a target may + // book onto a seat assigned to someone else, beyond the target's + // days-in-advance window, OR a seat the admin has disabled — so under + // book-for a disabled seat does NOT take the DISABLED early return; it + // runs the normal pipeline (CAN_BOOK / CAN_REBOOK / CAN_CHANGE / CAN_DELETE_*) + // so the click handler offers the right action (book, update when the target + // has a conflicting booking in the zone group, release when the target + // already holds it). Only under book-for (factory.login != the real login) + // and where `bookable` holds — which under book-for already means the actor + // administers the zone. Self-booking never overrides (105/106/110 apply). + // The disabled ICON is kept as a visual cue: _updateView forces the sprite + // to 'unavailable' for a disabled seat under book-for (the state still + // drives the actions; the icon just signals "this seat is off"). + const bookForOverride = this._isBookForOverride(); + + if (!this.enabled && !bookForOverride) { this.state = WarpSeat.SeatStates.DISABLED; return this.state; } - if (!this.bookable) { - // View-only access (PUBLIC_VIEW zone, or DISABLED zone even for admins) - // Existing own bookings can still be cancelled, but no new bookings allowed. - var hasOwnBooking = false; - for (var i of this._bookingsIterator()) { - if (i.book.login == this.factory.login) { - hasOwnBooking = true; - break; - } - } - if (hasOwnBooking) { - // Allow cancellation only (CAN_DELETE or CAN_DELETE_EXACT), not rebooking. - // Fall through to normal logic to compute isMine/isExact, but then - // override the state to prevent CAN_CHANGE / CAN_REBOOK / CAN_BOOK. - } else { - // No own booking — seat is not bookable, show as view-only or taken. - var isFree = true; - for (var i of this._bookingsIterator()) { - isFree = false; - break; - } - this.state = isFree ? WarpSeat.SeatStates.VIEW_ONLY : WarpSeat.SeatStates.VIEW_ONLY_TAKEN; - return this.state; - } - } - var assignedButNotForMe = false; + // (bookForOverride is hoisted above — see the comment near the !this.enabled + // check.) Under book-for a seat assigned to someone else, or beyond the + // target's days-in-advance window, falls through to CAN_BOOK: the seat then + // renders plain green `available` (third-party assignment) or blue + // `availableAssigned` (assigned to the target, beyond its window). if (Object.keys(this.assignments).length > 0) { @@ -409,8 +493,19 @@ WarpSeat.prototype._updateState = function() { const hasEveryone = everyoneData !== undefined; const userAssignment = this.assignments[this.factory.login]; const hasUserAssignment = userAssignment !== undefined; - - if (hasUserAssignment || hasEveryone) { + const hasSpecificAssignment = Object.keys(this.assignments).some(k => k !== EVERYONE_KEY); + + if (!this.bookable) { + // Under book-for/viewer access, a specific-login assignment (anyone's, + // including this acting user's own) is informational only — it never + // grants booking rights here, so any named assignee marks the seat as + // assigned to someone. An everyone-only assignment names no one, so it + // carries no information for a non-booker and is ignored (falls through + // to occupancy, i.e. CAN_BOOK below, later demoted to VIEW_ONLY). + if (hasSpecificAssignment) { + assignedButNotForMe = true; + } + } else if (hasUserAssignment || hasEveryone) { // Compute most-permissive days_in_advance across user's own row and everyone row let bestDays; if (hasUserAssignment) { @@ -430,15 +525,26 @@ WarpSeat.prototype._updateState = function() { // server-anchored: service timezone may differ from the client's var cutoffTs = window.warpGlobals.today + (bestDays + 1) * 24 * 3600; if (this.factory.selectedDates.some(d => d.fromTS >= cutoffTs)) { - this.state = WarpSeat.SeatStates.ASSIGNED; - return this.state; + if (bookForOverride) { + // Book-for overrides the days-in-advance window + // (apply() skips 110 under is_book_for): fall through + // to CAN_BOOK (green available / availableAssigned). + } else { + this.state = WarpSeat.SeatStates.ASSIGNED; + return this.state; + } } } // dates are within window — fall through to booking state } else { // Specific assignment(s) exist, but not for this user and no everyone row. - // The seat is effectively assigned to others; still show as booked if it is. - assignedButNotForMe = true; + if (bookForOverride) { + // Book-for overrides the assignment check (apply() skips 106 + // under is_book_for): fall through to CAN_BOOK (green available). + } else { + // The seat is effectively assigned to others; still show as booked if it is. + assignedButNotForMe = true; + } } } @@ -498,13 +604,17 @@ WarpSeat.prototype._updateState = function() { else this.state = WarpSeat.SeatStates.TAKEN; - // For non-bookable seats with own bookings, only allow cancellation. - // Clamp CAN_CHANGE / CAN_REBOOK / CAN_BOOK → CAN_DELETE. - if (!this.bookable && this.state != WarpSeat.SeatStates.CAN_DELETE_EXACT) { - if (this.state == WarpSeat.SeatStates.CAN_CHANGE || - this.state == WarpSeat.SeatStates.CAN_DELETE) { - this.state = WarpSeat.SeatStates.CAN_DELETE; - } + // Demote the one action state for !bookable seats (view-only zones, or + // book-for into a zone the actor doesn't administer): occupancy/assignment + // states are permission-independent, `bookable` only demotes CAN_BOOK. + // CAN_CHANGE (own booking, non-exact) is NOT demoted — a pure shrink is + // always allowed (apply()'s is_pure_shrink bypass + plan.js + // isSelectionShrinkOfMine), so the blue "yoursChange" icon and the Update + // action stay even in a view-only zone. CAN_REBOOK doesn't exist yet here + // — it's set in _updateView only for CAN_BOOK, which is already demoted + // below, so it can never fire for a !bookable seat. + if (!this.bookable && this.state == WarpSeat.SeatStates.CAN_BOOK) { + this.state = WarpSeat.SeatStates.VIEW_ONLY; } return this.state; @@ -515,13 +625,13 @@ WarpSeat.prototype._updateState = function() { // VIEW_ONLY side-effects applied in _updateView below. function spriteFor(state, assignedToMe) { switch (state) { - case WarpSeat.SeatStates.CAN_BOOK: return assignedToMe ? 'availableAssigned' : 'available'; + case WarpSeat.SeatStates.CAN_BOOK: + return assignedToMe ? 'availableAssigned' : 'available'; case WarpSeat.SeatStates.CAN_REBOOK: return assignedToMe ? 'rebookAssigned' : 'rebook'; case WarpSeat.SeatStates.CAN_CHANGE: return 'yoursChange'; case WarpSeat.SeatStates.CAN_DELETE_EXACT: return 'yours'; - case WarpSeat.SeatStates.CAN_DELETE: - case WarpSeat.SeatStates.TAKEN: - case WarpSeat.SeatStates.VIEW_ONLY_TAKEN: return 'taken'; + case WarpSeat.SeatStates.CAN_DELETE: return 'taken'; + case WarpSeat.SeatStates.TAKEN: return 'taken'; case WarpSeat.SeatStates.ASSIGNED: return 'assigned'; case WarpSeat.SeatStates.VIEW_ONLY: case WarpSeat.SeatStates.DISABLED: @@ -543,15 +653,24 @@ WarpSeat.prototype._updateView = function() { switch (this.state) { case WarpSeat.SeatStates.CAN_BOOK: - if (!this.bookable) - this.state = WarpSeat.SeatStates.VIEW_ONLY; - else if (this.factory._conflictCount(this.exclusivityKey) > 0) + // !bookable seats never reach _updateView as CAN_BOOK — they were + // already demoted to VIEW_ONLY at the end of _updateState. + if (this.factory._conflictCount(this.exclusivityKey) > 0) this.state = WarpSeat.SeatStates.CAN_REBOOK; // conflict map is final here break; // all other states are already final from _updateState } this.sprite = spriteFor(this.state, assignedToMe); + + // Book-for override of a seat-level disable: the disabled seat ran the + // normal pipeline (so its state drives the click actions — book / update / + // release) but the ICON stays the grey "unavailable" X as a visual cue that + // the seat is off. Only under book-for in an administered zone. (With no + // dates selected the state is NOT_AVAILABLE, whose sprite is already + // 'unavailable', so deriving the override here is safe for every state.) + if (!this.enabled && this._isBookForOverride()) + this.sprite = 'unavailable'; } WarpSeat.prototype._destroy = function() { diff --git a/js/views/plan.js b/js/views/plan.js index 9ec6c29..f1ecb72 100644 --- a/js/views/plan.js +++ b/js/views/plan.js @@ -6,7 +6,7 @@ import WarpModal from './modules/modal.js'; import {WarpSeatFactory,WarpSeat,EVERYONE_KEY} from './modules/seat.js'; import { OfficeMap } from './modules/officeMap.js'; import PlanUserData from './modules/planuserdata.js'; -import BookAs from './modules/bookas.js'; +import BookFor from './modules/bookfor.js'; import { WarpCalendar } from './modules/calendarGrid.js'; import { M } from '../app/materialize.js'; import warpDialog from '../app/dialog.js'; @@ -438,9 +438,15 @@ export async function mount(ctx) { function initActionMenu(seatFactory) { - if (window.warpGlobals.isZoneViewer) - return; - + // isZoneViewer no longer bails out: a pure viewer can still release + // their OWN booking from the plan map (CAN_DELETE/CAN_DELETE_EXACT → + // 'delete'). The click handler's state machine already opens no modal + // for non-actionable seats (VIEW_ONLY/NOT_AVAILABLE early-return; + // TAKEN/ASSIGNED push no action and isMyZoneAdmin() is false for a + // viewer so no seat-edit → actions empty → return before open()). + // Booking actions (book/rebook) can't fire for !bookable seats, and + // the auto-book FAB + book-for input stay hidden for viewers via their + // own data-requires/isZoneViewer gates. var seat = null; // used for passing seat to btn click events (closure) var assignedData = []; @@ -632,12 +638,21 @@ export async function mount(ctx) { var state = this.getState(); - if (state == WarpSeat.SeatStates.NOT_AVAILABLE || state == WarpSeat.SeatStates.VIEW_ONLY || state == WarpSeat.SeatStates.VIEW_ONLY_TAKEN) + if (state == WarpSeat.SeatStates.NOT_AVAILABLE || state == WarpSeat.SeatStates.VIEW_ONLY) return; var actions = []; var bookMsg = false; var removeMsg = false; + var blockedMsg = false; + var foreignRelease = false; + // Under book-for, the "own" states (CAN_CHANGE / CAN_DELETE / + // CAN_DELETE_EXACT) are the TARGET's bookings. Releasing one is a + // foreign release that apply() only allows in a zone the actor + // administers (seatsReqZoneAdmin). In a non-administered (!bookable) + // zone the release would 403 — don't offer a doomed action (mirrors + // the CAN_REBOOK hasUnmanageableConflict guard). + var bookForForeignRelease = this.factory.login !== window.warpGlobals.login && !this.bookable; switch (state) { case WarpSeat.SeatStates.CAN_BOOK: @@ -645,20 +660,62 @@ export async function mount(ctx) { bookMsg = true; break; case WarpSeat.SeatStates.CAN_CHANGE: - actions.push('delete'); - // no break here - case WarpSeat.SeatStates.CAN_REBOOK: - actions.push('update'); - bookMsg = removeMsg = true; - break; case WarpSeat.SeatStates.CAN_DELETE: case WarpSeat.SeatStates.CAN_DELETE_EXACT: + if (bookForForeignRelease) break; // target's booking in a non-administered zone — release would 403 + // Delete/update remove the acting user's conflicting + // bookings across the whole zone group. Under book-for + // that set can include the target's booking in a zone the + // actor doesn't administer — apply() 403s that foreign + // remove (code 102) and rolls the whole request back, so + // explain instead of offering a doomed action (same guard + // as CAN_REBOOK below; false outside book-for). + if (seatFactory.hasUnmanageableConflict(this)) { + blockedMsg = true; + break; + } actions.push('delete'); removeMsg = true; + // Update extends/changes the booking — only offer it where + // the actor may book (this.bookable) or the selection is a + // pure shrink of an own booking (always allowed, even in a + // view-only / DISABLED zone — apply()'s is_pure_shrink). + if (state == WarpSeat.SeatStates.CAN_CHANGE && + (this.bookable || this.isSelectionShrinkOfMine())) { + actions.push('update'); + bookMsg = true; + } + break; + case WarpSeat.SeatStates.CAN_REBOOK: + // Under book-for, updating this free seat would release the + // target's conflicting booking elsewhere in the zone group. + // If that booking is outside the zones we administer, apply() + // rejects the release (403/102) and the whole request rolls + // back — don't offer an action that's guaranteed to fail. + if (seatFactory.hasUnmanageableConflict(this)) { + blockedMsg = true; + } else { + actions.push('update'); + bookMsg = removeMsg = true; + } + break; + case WarpSeat.SeatStates.TAKEN: + // A zone admin may release another user's booking on a seat + // in a zone they administer (apply()'s remove requires + // per-seat zone-admin for foreign bookings). Non-admins get + // no action — TAKEN stays informational. + if (this.isMyZoneAdmin()) { + actions.push('delete'); + removeMsg = true; + foreignRelease = true; + } break; }; - if (window.warpGlobals.isZoneAdmin) { + // Per-seat check: isZoneAdmin is plan-wide (true if the actor admins + // ANY zone on the plan), which would otherwise offer seat-edit (and + // its always-403 save) on seats in zones the actor merely views. + if (this.isMyZoneAdmin()) { actions.push('seat-edit'); actions.push('seat-edit-save'); } @@ -669,6 +726,12 @@ export async function mount(ctx) { let msg1El = root.querySelector("#action_modal_msg1"); msg1El.innerHTML = ""; + if (blockedMsg) { + let p = document.createElement('P'); + p.innerText = TR("This seat can't be updated here: the conflicting booking is in a zone you don't administer."); + msg1El.appendChild(p); + } + if (bookMsg) { var bookDatesTable = document.createElement("table"); @@ -691,11 +754,22 @@ export async function mount(ctx) { if (removeMsg) { + // For a zone-admin release of someone else's booking on this + // seat, list the foreign booking(s); otherwise the acting + // user's own same-group conflicts (an update/delete). + var releaseList = foreignRelease + ? this.getForeignBookings() + : seatFactory.getMyConflictingBookings(this); + var myConflictsTable = document.createElement("table"); - for (let c of seatFactory.getMyConflictingBookings(this)) { + for (let c of releaseList) { let tr = myConflictsTable.appendChild(document.createElement("tr")); - tr.appendChild( document.createElement("td")).innerText = c.zone_name - tr.appendChild( document.createElement("td")).innerText = c.seat_name; + if (foreignRelease) { + tr.appendChild( document.createElement("td")).innerText = c.username; + } else { + tr.appendChild( document.createElement("td")).innerText = c.zone_name + tr.appendChild( document.createElement("td")).innerText = c.seat_name; + } tr.appendChild( document.createElement("td")).innerText = c.datetime1; tr.appendChild( document.createElement("td")).innerText = c.datetime2; } @@ -712,6 +786,7 @@ export async function mount(ctx) { } seat = this; + seat.foreignRelease = foreignRelease; actionModal.open(); }); @@ -753,14 +828,19 @@ export async function mount(ctx) { } if (window.warpGlobals.isZoneAdmin) { - let login = BookAs.getInstance().getSelectedLogin(true); + let login = BookFor.getInstance().getSelectedLogin(true); if (login !== null) applyData['book']['login'] = login; } } if (this.dataset.action == 'delete' || this.dataset.action == 'update') { - applyData['remove'] = seatFactory.getMyConflictingBookings(seat, true); + // A zone-admin release of another user's booking (TAKEN seat) + // targets that foreign booking's bid, not the acting user's own + // same-group conflicts (which an update/own-release removes). + applyData['remove'] = (this.dataset.action == 'delete' && seat.foreignRelease) + ? seat.getForeignBookings(true) + : seatFactory.getMyConflictingBookings(seat, true); } // seat-edit-save with no net changes (toggle flipped back, assignments @@ -868,7 +948,6 @@ export async function mount(ctx) { rebook: 'rebook', conflict: 'taken', viewOnly: 'unavailable', - viewOnlyTaken: 'taken', userExact: 'yours', userRebook: 'yoursChange', userConflict: 'taken', @@ -924,9 +1003,9 @@ export async function mount(ctx) { } } - function initBookAs(seatFactory) { + function initBookFor(seatFactory) { - BookAs.getInstance().on('change', function(newLogin) { + BookFor.getInstance().on('change', function(newLogin) { // Full refresh under the new acting login. downloadSeatData adds // ?login= when newLogin differs from our own, so the server returns a @@ -970,7 +1049,7 @@ export async function mount(ctx) { var payload = { dates: dates }; if (window.warpGlobals.isZoneAdmin) { - let login = BookAs.getInstance().getSelectedLogin(true); + let login = BookFor.getInstance().getSelectedLogin(true); if (login !== null) payload['login'] = login; } @@ -1130,7 +1209,7 @@ export async function mount(ctx) { if (window.warpGlobals.isZoneAdmin) { PlanUserData.init(); - initBookAs(seatFactory); + initBookFor(seatFactory); } return function unmount() { @@ -1143,10 +1222,10 @@ export async function mount(ctx) { if (slider && slider.noUiSlider) slider.noUiSlider.destroy(); // Both are app-wide module singletons (getInstance()) that guard // against double-init — a re-mount (this plan again, or a different - // one) must clear their state or PlanUserData.init()/initBookAs() + // one) must clear their state or PlanUserData.init()/initBookFor() // throw "already initialized" instead of loading fresh data. if (PlanUserData.instance) PlanUserData.instance.reset(); - if (BookAs.instance) BookAs.instance.reset(); + if (BookFor.instance) BookFor.instance.reset(); }; } diff --git a/warp/static/i18n/de.json b/warp/static/i18n/de.json index 3e213bf..cdd1d26 100644 --- a/warp/static/i18n/de.json +++ b/warp/static/i18n/de.json @@ -169,16 +169,17 @@ "The seat is assigned to you and available to be booked.": "Der Sitzplatz ist Ihnen zugewiesen und kann gebucht werden.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Der Sitzplatz ist Ihnen zugewiesen und verfügbar, aber Sie haben eine andere Buchung zu diesem Zeitpunkt, die automatisch aktualisiert wird.", "The seat is booked by you exactly on the selected date and time.": "Der Sitzplatz wird von Ihnen genau für das ausgewählte Datum und die Uhrzeit gebucht.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit. Sie können aktualisieren; andere Reservierungen zu dieser Zeit werden automatisch aktualisiert.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Der Sitzplatz ist von Ihnen gebucht, aber nicht genau zum gewählten Datum oder zur Uhrzeit — klicken Sie, um ihn freizugeben oder auf die gewählte Zeit zu aktualisieren.", "The seat is booked by someone else or not available for booking.": "Der Platz ist von einer anderen Person gebucht oder nicht zur Buchung verfügbar.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "Der Platz ist nicht zur Buchung verfügbar: keine Daten ausgewählt, deaktiviert oder Nur-Ansicht-Zone.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Der Platz steht Ihnen nicht zur Buchung zur Verfügung: keine Daten ausgewählt, deaktiviert oder nichts zu buchen in einer Nur-Ansicht-Zone.", "The seat is assigned to some people but currently not booked.": "Der Platz ist bestimmten Personen zugewiesen, aber derzeit nicht gebucht.", - "Book as": "Buchen als", + "Book for": "Buchen für", "Seat %{seat_name}": "Sitz %{seat_name}", "Assigned to:": "zugeordnet zu:", "Bookings:": "Buchungen:", "Seat %{seat_name} to be booked:": "Sitzplatz %{seat_name} soll gebucht werden:", "To be released:": "Soll freigegeben werden:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Dieser Platz kann hier nicht aktualisiert werden: Die widersprüchliche Buchung befindet sich in einer Zone, die Sie nicht verwalten.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Sitzplatz ist erfolgreich deaktiviert.
Es gibt jedoch bestehende Reservierungen für die nächsten Wochen. Bestehende Reservierungen werden nicht automatisch freigegeben, dies muss manuell geschehen.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Der Sitzplatz wurde erfolgreich zugewiesen.
Allerdings gibt es in den nächsten Wochen bestehende Reservierungen von Nicht-Empfängern. Bestehende Reservierungen werden nicht automatisch freigegeben, sondern müssen manuell vorgenommen werden.

", "Warning": "Warnung", diff --git a/warp/static/i18n/en.json b/warp/static/i18n/en.json index 97badd9..a210afc 100644 --- a/warp/static/i18n/en.json +++ b/warp/static/i18n/en.json @@ -169,16 +169,17 @@ "The seat is assigned to you and available to be booked.": "The seat is assigned to you and available to be booked.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.", "The seat is booked by you exactly on the selected date and time.": "The seat is booked by you exactly on the selected date and time.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.", "The seat is booked by someone else or not available for booking.": "The seat is booked by someone else or not available for booking.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "The seat is not available for booking: no dates selected, disabled, or view-only zone.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.", "The seat is assigned to some people but currently not booked.": "The seat is assigned to some people but currently not booked.", - "Book as": "Book as", + "Book for": "Book for", "Seat %{seat_name}": "Seat %{seat_name}", "Assigned to:": "Assigned to:", "Bookings:": "Bookings:", "Seat %{seat_name} to be booked:": "Seat %{seat_name} to be booked:", "To be released:": "To be released:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "This seat can't be updated here: the conflicting booking is in a zone you don't administer.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

", "Warning": "Warning", diff --git a/warp/static/i18n/es.json b/warp/static/i18n/es.json index 1c8043e..cad27dc 100644 --- a/warp/static/i18n/es.json +++ b/warp/static/i18n/es.json @@ -169,16 +169,17 @@ "The seat is assigned to you and available to be booked.": "El asiento está asignado a usted y disponible para reservar.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "El asiento está asignado a usted y disponible, pero usted tiene otra reserva en ese horario que se actualizará automáticamente.", "The seat is booked by you exactly on the selected date and time.": "Este asiento está reservado por usted exactamente en la fecha y hora seleccionadas.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada. Puede actualizarla; sus otras reservas en ese horario se actualizarán automáticamente.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Este asiento está reservado por usted, pero no exactamente en la fecha u hora seleccionada — haga clic para liberarlo o actualizarlo al tiempo seleccionado.", "The seat is booked by someone else or not available for booking.": "Este asiento está reservado por otra persona o no está disponible para reservar.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "El asiento no está disponible para reservar: no hay fechas seleccionadas, está deshabilitado o es una zona de solo lectura.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "El asiento no está disponible para que usted lo reserve: no hay fechas seleccionadas, está deshabilitado o no hay nada que reservar en una zona de solo lectura.", "The seat is assigned to some people but currently not booked.": "El asiento está asignado a algunas personas pero actualmente no está reservado.", - "Book as": "Reservar como", + "Book for": "Reservar para", "Seat %{seat_name}": "Asiento %{seat_name}", "Assigned to:": "Asignado a:", "Bookings:": "Reservas:", "Seat %{seat_name} to be booked:": "Asiento %{seat_name} para ser reservado:", "To be released:": "A liberar:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Este asiento no se puede actualizar aquí: la reserva en conflicto está en una zona que usted no administra.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "El asiento fué deshabilitado correctamente.
Sin embargo, hay reservas existentes para las próximas pocas semanas. Las reservas existentes no se liberarán automáticamente, se deberá realizar manualmente!

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "El asiento fué correctamente asignado.
Sin embargo, hay reservas existentes de no asignados en las próximas semanas. Las reservas existentes no se liberan automáticamente, tiene que hacerse manualmente.

", "Warning": "Advertencia", diff --git a/warp/static/i18n/fr.json b/warp/static/i18n/fr.json index 2420c59..2bf48e0 100644 --- a/warp/static/i18n/fr.json +++ b/warp/static/i18n/fr.json @@ -169,16 +169,17 @@ "The seat is assigned to you and available to be booked.": "Le siège vous est assigné et disponible à la réservation.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Le siège vous est assigné et disponible, mais vous avez une autre réservation à cet horaire qui sera automatiquement mise à jour.", "The seat is booked by you exactly on the selected date and time.": "Le siège est réservé par vous exactement à la date et l'horaire sélectionnés.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné. Vous pouvez le mettre à jour ; vos autres réservations à cet horaire seront automatiquement mises à jour.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Le siège est réservé par vous, mais pas exactement à la date ou l'horaire sélectionné — cliquez pour le libérer ou le mettre à jour à l'horaire sélectionné.", "The seat is booked by someone else or not available for booking.": "Le siège est réservé par quelqu'un d'autre ou non disponible à la réservation.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "Le siège n'est pas disponible à la réservation : aucune date sélectionnée, désactivé, ou zone en lecture seule.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "Le siège n'est pas disponible pour vous à la réservation : aucune date sélectionnée, désactivé, ou rien à réserver dans une zone en lecture seule.", "The seat is assigned to some people but currently not booked.": "Le siège est assigné à certaines personnes mais actuellement non réservé.", - "Book as": "Réserver en tant que", + "Book for": "Réserver pour", "Seat %{seat_name}": "Siège %{seat_name}", "Assigned to:": "Affecté à:", "Bookings:": "Réservations:", "Seat %{seat_name} to be booked:": "Le siège %{seat_name} est réservé:", "To be released:": "À libérer:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Ce siège ne peut pas être mis à jour ici : la réservation en conflit se trouve dans une zone que vous n'administrez pas.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Siège désactivé avec succès.
Cependant, il y a des réservations pour les prochaines semaines. Les réservations existantes ne sont pas automatiquement libérées, ce doit être fait manuellement.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Siège assigné avec succès.
Cependant, il y des réservations de personnes non-assignées pour les prochaines semaines. Les réservations existantes ne sont pas automatiquement libérées, ce doit être fait manuellement.

", "Warning": "Avertissement", diff --git a/warp/static/i18n/pl.json b/warp/static/i18n/pl.json index bdafff4..f3a613e 100644 --- a/warp/static/i18n/pl.json +++ b/warp/static/i18n/pl.json @@ -169,16 +169,17 @@ "The seat is assigned to you and available to be booked.": "Miejsce jest przypisane do Ciebie i dostępne do rezerwacji.", "The seat is assigned to you and available, but you have another booking at that time which will be automatically updated.": "Miejsce jest przypisane do Ciebie i dostępne, ale posiadasz inną rezerwację w tym samym czasie, która zostanie automatycznie zaktualizowana.", "The seat is booked by you exactly on the selected date and time.": "W wybranym czasie miejsce jest dokładnie zarezerwowane przez Ciebie.", - "The seat is booked by you, but not exactly on the selected date or time. You can update; other reservations at that time will be automatically updated.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie. Możesz uaktualnić rezerwację; inne rezerwacje w tym czasie zostaną automatycznie zaktualizowane.", + "The seat is booked by you, but not exactly on the selected date or time — click to release it or update it to the selected time.": "Miejsce jest zarezerwowane przez Ciebie, ale nie dokładnie w wybranym czasie — kliknij, aby je zwolnić lub uaktualnić do wybranego czasu.", "The seat is booked by someone else or not available for booking.": "Miejsce jest zarezerwowane przez kogoś innego lub niedostępne do rezerwacji.", - "The seat is not available for booking: no dates selected, disabled, or view-only zone.": "Miejsce jest niedostępne do rezerwacji: nie wybrano dat, wyłączone lub strefa tylko do odczytu.", + "The seat is not available for you to book: no dates selected, disabled, or nothing to book in a view-only zone.": "To miejsce nie jest dostępne do rezerwacji: nie wybrano dat, jest wyłączone lub nie ma nic do zarezerwowania w strefie tylko do odczytu.", "The seat is assigned to some people but currently not booked.": "Miejsce jest przypisane do niektórych osób, ale obecnie nie jest zarezerwowane.", - "Book as": "Zarezerwuj jako", + "Book for": "Zarezerwuj dla", "Seat %{seat_name}": "Miejsce %{seat_name}", "Assigned to:": "Przypisane do:", "Bookings:": "Rezerwacje:", "Seat %{seat_name} to be booked:": "Miejsce %{seat_name} zostanie zarezerwowane:", "To be released:": "Rezerwacje zostaną zwolnione:", + "This seat can't be updated here: the conflicting booking is in a zone you don't administer.": "Tego miejsca nie można tutaj zaktualizować: konfliktowa rezerwacja znajduje się w strefie, którą nie zarządzasz.", "Seat is successfully disabled.
However there are existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Miejsce zostało wyłączone.
Jednak w kolejnych dniach istnieją jego rezerwacje. Istniejące rezerwacje nie są zwalniane automatycznie, należy zwolnić je ręcznie.

", "Seat is successfully assigned.
However there are non-assignees' existing reservations in the the next few weeks. Existing reservations are not automatically released, it has to be done manually.

": "Miejsce zostało przypisane.
Jednak w kolejnych dniach istnieję rezerwacje zrobione przez osoby, które nie zostały do niego przypisane. Istniejące rezerwacje nie są zwalniane automatycznie, należy zwolnić je ręcznie.

", "Warning": "Uwaga", diff --git a/warp/xhr/bookings.py b/warp/xhr/bookings.py index 0b8fa5c..a4ccd41 100644 --- a/warp/xhr/bookings.py +++ b/warp/xhr/bookings.py @@ -2,7 +2,7 @@ import xlsxwriter import io from jsonschema import validate, ValidationError -from peewee import SQL, Expression, fn +from peewee import SQL, Expression, fn, JOIN from warp.db import * from warp import utils @@ -111,10 +111,23 @@ def listW(report = False): # list is a built-in type # (mirrors users.delete, which compares view timestamptz columns). # today_in_tz_sql encapsulates the e2e debug time-offset. today_in_plan_tz = utils.today_in_tz_sql(Plan.timezone, as_epoch=True) + # LEFT JOIN the actor's own role row: a matching row exists iff they + # have effective access to the booking's zone (synthetic public-zone + # rows included). A user's OWN booking in a zone they no longer have a + # role for (access revoked after booking, or booked there via book-for + # then removed) must still be visible + deletable here — the plan map + # can't reach a seat in a zone they can't open, so the bookings table + # is the only UI path. Admit those own rows via Book.login == g.login; + # other users' bookings in no-access zones stay hidden (the disjunct + # only admits own rows). zone_role is NULL for the no-role case. query = query.select_extend(UserToZoneRoles.zone_role) \ - .join(UserToZoneRoles, on=(UserToZoneRoles.zid == Seat.zid)) \ - .where((UserToZoneRoles.login == flask.g.login) & - (_FROM_UTC_SQL >= today_in_plan_tz)) + .join(UserToZoneRoles, + join_type=JOIN.LEFT_OUTER, + on=((UserToZoneRoles.zid == Seat.zid) & + (UserToZoneRoles.login == flask.g.login))) \ + .where((_FROM_UTC_SQL >= today_in_plan_tz) & + (UserToZoneRoles.zone_role.is_null(False) | + (Book.login == flask.g.login))) columnsMap = { "id": Book.id, @@ -235,11 +248,16 @@ def funOperator(field,value): if not report: - # zone_role from the view IS the effective role. A row exists - # iff the user has access, so no LEFT JOIN / zone_type predicate. - d['rw'] = \ - (row["login"] == flask.g.login and row['zone_role'] <= ZONE_ROLE_USER) \ - or row['zone_role'] <= ZONE_ROLE_ADMIN + # rw = deletable via the bookings-table delete icon. A user can + # always release their OWN booking: apply()'s remove bypasses + # the per-seat zone-admin check for own bookings (the + # Book.login != g.login filter), so this is true even when they + # are only a VIEWER in the zone, or have lost access entirely + # (zone_role NULL via the LEFT JOIN above). Foreign bookings + # stay gated by the actor's zone-admin standing. + own = row["login"] == flask.g.login + role = row['zone_role'] + d['rw'] = own or (role is not None and role <= ZONE_ROLE_ADMIN) else: d["login"] = row["login"] diff --git a/warp/xhr/plan.py b/warp/xhr/plan.py index 338f1d4..c456636 100644 --- a/warp/xhr/plan.py +++ b/warp/xhr/plan.py @@ -113,17 +113,19 @@ def getContext(pid): # } # } # -# bookable: true if the relevant user's effective role for this seat's zone is -# ≤ ZONE_ROLE_USER (i.e. they can actually book the seat). False for -# VIEWER-only access or for seats in DISABLED zones (even if admin there -# — disabled zones are fully locked down and should not allow booking -# from the UI). The "relevant user" is normally the authenticated user, -# but under book-as (?login=target) it is the target user, so the UI -# only offers seats apply()/autoBook would actually let them book. +# bookable: true if the acting user can book this seat. Normally the +# authenticated user's effective role for the zone must be +# ≤ ZONE_ROLE_USER. False for VIEWER-only access or for seats in +# DISABLED zones (even if admin there — disabled zones are fully +# locked down). Under book-for (?login=target) the acting user is +# the admin, not the target: bookable reflects whether the admin +# administers the zone and the target is a member of it, so the UI +# only offers seats apply()/autoBook would actually let them book for. # # Optional query args: -# login=string – view/operate the plan as this login (book-as; requires -# plan admin). Conflict bookings are scoped to this login. +# login=string – book for this login (requires the acting user to +# administer the seat's zone). Conflict bookings are +# scoped to this login. def resolve_conflict_bookings(conflict_zids, login, open_tz, tr, exclude_sids=()): """Resolve a user's conflicting bookings for the plan being opened. @@ -243,7 +245,7 @@ def getSeats(pid): accessible_zids = set(effective_roles.keys()) is_plan_admin = any(r <= ZONE_ROLE_ADMIN for r in effective_roles.values()) - # Book-as: ?login=target lets a plan admin view and operate the plan as + # Book-for: ?login=target lets a plan admin view and operate the plan for # another user (requires plan admin). Fetched once and reused below. targetLogin = flask.request.args.get('login') @@ -256,7 +258,7 @@ def getSeats(pid): # at least one administered zone (non-public) or, for a public plan, the # admin has a synthetic row on the public zone and every user has one # too — so any non-group user validates. The view's account_type < 100 - # filter excludes group logins (more correct for book-as than the old + # filter excludes group logins (more correct for book-for than the old # any_public branch, which only checked Users.login existence). loginInPlan = UserToZoneRoles.select(SQL_ONE) \ .where(UserToZoneRoles.zid.in_(list(accessible_zids))) \ @@ -266,22 +268,22 @@ def getSeats(pid): return {"msg": "Forbidden", "code": 132}, 403 # Whose booking permission decides `bookable`. Normally the authenticated - # user. Under book-as (?login=target) the admin views the plan through the - # target's eyes, so `bookable` must reflect whether *target* can book each - # seat — otherwise seats the target can't book (e.g. VIEWER-only zones) would - # look bookable yet apply()/autoBook would reject them (code 104). Visibility - # of seats still follows the admin's (effective_roles) access. - bookable_roles = effective_roles + # user (role <= USER). Under book-for (?login=target) the actor books ON + # BEHALF OF the target: `bookable` reflects whether the *actor* administers + # the zone (role <= ADMIN) and the target is a member of it — the actor can + # override seat-level restrictions (assignments) there, so target role value + # doesn't matter, only that a UserToZoneRoles row exists. Visibility of seats + # still follows the admin's (effective_roles) access. + target_membership_zids = None if targetLogin is not None and targetLogin != flask.g.login: # Roles from the view are already effective — query once for the target. - target_roles = {} + target_membership_zids = set() if accessible_zids: - for row in UserToZoneRoles.select(UserToZoneRoles.zid, UserToZoneRoles.zone_role) \ + for row in UserToZoneRoles.select(UserToZoneRoles.zid) \ .where(UserToZoneRoles.zid.in_(list(accessible_zids))) \ .where(UserToZoneRoles.login == targetLogin) \ .iterator(): - target_roles[row['zid']] = row['zone_role'] - bookable_roles = target_roles + target_membership_zids.add(row['zid']) tr = utils.getTimeRange(tz=plan_tz) usedZids = set() @@ -313,14 +315,18 @@ def getSeats(pid): if not s['enabled'] and zone_role > ZONE_ROLE_ADMIN: continue - book_role = bookable_roles.get(zid) + if target_membership_zids is not None: + bookable = zone_role <= ZONE_ROLE_ADMIN and zid in target_membership_zids \ + and zone_type_map[zid] != ZONE_TYPE_DISABLED + else: + bookable = zone_role <= ZONE_ROLE_USER and zone_type_map[zid] != ZONE_TYPE_DISABLED seatD = { "name": s['name'], "x": s['x'], "y": s['y'], "zid": zid, "enabled": s['enabled'] != 0, - "bookable": book_role is not None and book_role <= ZONE_ROLE_USER and zone_type_map[zid] != ZONE_TYPE_DISABLED, + "bookable": bookable, "book": [] } if s['id'] in assignments: @@ -398,6 +404,38 @@ def getSeats(pid): res['zones'][str(i[0])] = i[1] res['zoneGroups'][str(i[0])] = i[2] + # zoneAdmin reflects the ACTOR's own admin standing (not bookable_roles / + # targetLogin) — seat-edit is an admin action performed by the actor + # regardless of who book-for is viewing as. The frontend also uses it in + # hasUnmanageableConflict() to decide whether a book-for "update" can + # release the target's conflicting booking. effective_roles is plan-scoped + # (zones ON this plan), but a conflict booking can sit in a same-group zone + # on ANOTHER plan, so effective_roles alone would mark an administered + # cross-plan conflict zone as not-administered and wrongly block the + # update. Query the actor's roles for every zone in the response (plan + + # conflict) so cross-plan release confinement is judged correctly. Site + # admins administer every zone. + if flask.g.isAdmin: + admin_zids = set(usedZids) + else: + # effective_roles already holds the actor's effective role for every + # zone on this plan; only zones pulled in by conflict bookings and + # absent from it (cross-plan same-group zones) need a fresh role + # lookup — in the common case there are none and no query runs. + admin_zids = {zid for zid in usedZids + if zid in effective_roles + and effective_roles[zid] <= ZONE_ROLE_ADMIN} + crossplan_zids = [zid for zid in usedZids if zid not in effective_roles] + if crossplan_zids: + admin_zids |= { + r['zid'] for r in UserToZoneRoles.select(UserToZoneRoles.zid) + .where(UserToZoneRoles.zid.in_(crossplan_zids)) + .where(UserToZoneRoles.login == flask.g.login) + .where(UserToZoneRoles.zone_role <= ZONE_ROLE_ADMIN) + .iterator() + } + res['zoneAdmin'] = {str(zid): (zid in admin_zids) for zid in usedZids} + usedUsersQuery = Users.select(Users.login, Users.name).where(Users.login.in_(usedUsers)).tuples() res['users'] = {str(i[0]): i[1] for i in usedUsersQuery.iterator()} @@ -511,13 +549,43 @@ def apply(): if 'book' in apply_data: - if not flask.g.isAdmin: + # Pure-shrink bypass: a self update + # (no book.login) whose every booked range is fully covered by one of + # the actor's own bookings being removed on this seat only narrows an + # existing own booking. A shrink never increases exposure, so it skips + # every booking check below (role 104, DISABLED zone 104, seat-disabled + # 105, assignment 106/110, horizon 103) — release is the shrink-to-zero + # case and is already ungated; this generalises it. Book-for is never a + # shrink (is_book_for excluded). The remove bids' ownership is verified + # server-side by the login filter (do not trust the client). + is_pure_shrink = False + if 'remove' in apply_data and 'login' not in apply_data['book']: + # Book.fromts/tots are seconds-of-day-anchored epochs; the JSON + # payload uses fromTS/toTS. r = (id, fromts, tots). + own_rows = list(Book.select(Book.id, Book.fromts, Book.tots) + .where(Book.id.in_(apply_data['remove'])) + .where(Book.login == flask.g.login) + .where(Book.sid == apply_data['book']['sid']) + .tuples()) + if own_rows: + def _covered(d): + return any(r[1] <= d['fromTS'] and r[2] >= d['toTS'] for r in own_rows) + if all(_covered(d) for d in apply_data['book']['dates']): + is_pure_shrink = True + + if not flask.g.isAdmin and not is_pure_shrink: for b in apply_data['book']['dates']: if b['fromTS'] < ts["fromTS"] or b['fromTS'] > ts["toTS"] \ or b['toTS'] < ts["fromTS"] or b['toTS'] > ts["toTS"]: return {"msg": "Forbidden", "code": 103}, 403 sid = apply_data['book']['sid'] + # Explicit book.login means book-for: the actor books on behalf of the + # target. The actor's zone-admin status for this seat was already + # enforced above via seatsReqZoneAdmin (code 102), so here we only + # confirm the target is a member of the seat's zone and skip the + # seat-level assignment checks (106/110) — a zone admin may override them. + is_book_for = 'login' in apply_data['book'] login = apply_data['book'].get('login', flask.g.login) seatZone = Seat.select(Seat.enabled, Seat.zid, Zone.zone_type.alias('zone_type')) \ @@ -535,19 +603,29 @@ def apply(): # bookerRole from the view IS the effective role. isSelfAdminBooking = flask.g.isAdmin and login == flask.g.login - if not isSelfAdminBooking and (bookerRole is None or bookerRole > ZONE_ROLE_USER): - return {"msg": "Forbidden", "code": 104}, 403 + if not isSelfAdminBooking and not is_pure_shrink: + # Book-for needs only target membership (any role row); self-book + # needs role <= USER. + if bookerRole is None or (not is_book_for and bookerRole > ZONE_ROLE_USER): + return {"msg": "Forbidden", "code": 104}, 403 # Disabled zones cannot be booked at all — even by admins. - # Enable the zone first, then book. - if seatZone['zone_type'] == ZONE_TYPE_DISABLED: + # Enable the zone first, then book. (A pure shrink is still allowed — + # the seat was bookable when the user booked it.) + if seatZone['zone_type'] == ZONE_TYPE_DISABLED and not is_pure_shrink: return {"msg": "Forbidden", "code": 104}, 403 - if not seatZone['enabled']: + if not seatZone['enabled'] and not is_book_for and not is_pure_shrink: + # A zone admin may book-for onto a seat they have disabled (they + # could re-enable it anyway) — the zone-admin gate already ran via + # seatsReqZoneAdmin. Self-booking and pure shrinks are handled by + # the is_pure_shrink guard above; this skip is book-for-only. return {"msg": "Forbidden", "code": 105}, 403 - assignedQ = SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid) - if assignedQ.scalar() is not None: + # Assignment checks (106/110) are skipped for book-for (a zone admin + # may override them) and for pure shrinks (always allowed). + if not is_book_for and not is_pure_shrink \ + and SeatAssign.select(SQL_ONE).where(SeatAssign.sid == sid).scalar() is not None: myAssignments = list(SeatAssign.select(SeatAssign.days_in_advance) .where((SeatAssign.sid == sid) & ((SeatAssign.login == login) | SeatAssign.login.is_null())) @@ -723,15 +801,18 @@ class ApplyError(Exception): } -def runAutoBook(login, pid, dates, allowedZids=None, releaseZids=None): +def runAutoBook(login, pid, dates, allowedZids=None, releaseZids=None, is_book_for=False): """Core autobook algorithm. Selects the best seat on the plan (pid) for the given login and dates. When allowedZids is given, only seats in those zones are eligible — used to - confine a zone admin booking *as* another user to the zones they administer. + confine a zone admin booking *for* another user to the zones they administer. When releaseZids is given, existing bookings may only be deleted if they are - in one of those zones — used to confine the release side of a book-as operation. + in one of those zones — used to confine the release side of a book-for operation. None means no confinement (current behaviour for self-book / iCal / site admin). + When is_book_for is True, the subject's zone role is not required to be + <= USER — a zone admin overrides seat-level membership restrictions when + booking for someone else, so only membership (a UserToZoneRoles row) matters. Returns (result_dict, None) on success or (None, error_code) on failure. Error codes: 103 (time out of window), 140 (overlapping dates), 109 (DB conflict), 130 (bad pid). """ @@ -784,8 +865,10 @@ def runAutoBook(login, pid, dates, allowedZids=None, releaseZids=None): .where(Seat.pid == pid) \ .where(Seat.enabled == True) \ .where(Zone.zone_type != ZONE_TYPE_DISABLED) \ - .where(UserToZoneRoles.login == login) \ - .where(UserToZoneRoles.zone_role <= ZONE_ROLE_USER) + .where(UserToZoneRoles.login == login) + + if not is_book_for: + accessibleSeatQ = accessibleSeatQ.where(UserToZoneRoles.zone_role <= ZONE_ROLE_USER) if allowedZids is not None: allowedZidsList = list(allowedZids) @@ -1054,7 +1137,7 @@ def autoBook(pid): payload = flask.request.get_json() dates = payload['dates'] login = payload.get('login', flask.g.login) - is_book_as = login != flask.g.login + is_book_for = login != flask.g.login # Zones (with type) that have seats on this plan. zone_type_map = { @@ -1075,12 +1158,12 @@ def _rolesFor(loginToCheck): roles[r['zid']] = r['zone_role'] return roles - # Confine book-as to zones the actor administers: the seat pool + # Confine book-for to zones the actor administers: the seat pool # (allowedZids) and the set of bookings that may be released # (releaseZids) are both limited to manageableZids. None means # unconfined (self-book, site admin, iCal). manageableZids = None - if is_book_as and not flask.g.isAdmin: + if is_book_for and not flask.g.isAdmin: actor_roles = _rolesFor(flask.g.login) # Roles from the view are already effective — synthetic public-zone rows # are always USER/VIEWER (never ADMIN), so they cannot inflate @@ -1095,10 +1178,12 @@ def _rolesFor(loginToCheck): subject_roles = _rolesFor(login) zone_iter = manageableZids if manageableZids is not None else zone_type_map.keys() # Roles from the view are already effective; DISABLED is a business rule. + # Under book-for, the actor already administers zone_iter (manageableZids) + # or is a site admin, so only membership is required — not role <= USER. can_book = any( zone_type_map[zid] != ZONE_TYPE_DISABLED and subject_roles.get(zid) is not None - and subject_roles.get(zid) <= ZONE_ROLE_USER + and (is_book_for or subject_roles.get(zid) <= ZONE_ROLE_USER) for zid in zone_iter ) if not can_book: @@ -1106,7 +1191,8 @@ def _rolesFor(loginToCheck): result, err = runAutoBook(login, pid, dates, allowedZids=manageableZids, - releaseZids=manageableZids) + releaseZids=manageableZids, + is_book_for=is_book_for) if err == 103: return {"msg": "Forbidden", "code": 103}, 403