From aa3436fd1d407289a1bc31d10266ca87dc1c9e71 Mon Sep 17 00:00:00 2001 From: wolf-demon Date: Tue, 29 Sep 2026 21:03:17 +0100 Subject: [PATCH] mileztone17 --- MILESTONES.md | 2 +- RELEASE_NOTES.md | 4 +- deploy/desktop-acceptance.example.json | 31 +++++++ deploy/desktop_acceptance.py | 121 +++++++++++++++++++++++++ docs/desktop-acceptance.md | 28 ++++++ docs/pilot-release.md | 2 + tests/test_desktop_acceptance.py | 65 +++++++++++++ web/src/PaymentsPage.tsx | 3 +- web/src/PmsPage.tsx | 3 +- web/src/TeamPage.tsx | 7 +- web/src/main.tsx | 8 +- 11 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 deploy/desktop-acceptance.example.json create mode 100644 deploy/desktop_acceptance.py create mode 100644 docs/desktop-acceptance.md create mode 100644 tests/test_desktop_acceptance.py diff --git a/MILESTONES.md b/MILESTONES.md index 1025011..07ff8de 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -41,7 +41,7 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i | 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. | | 15 | Knowledge, AI, and FAQ activation | B | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. | | 16 | Identity, preferences, and privacy | B/C | In progress | Login throttling now uses the client address only after one-hop processing from the explicitly trusted reverse proxy. Finish privacy/retention decisions, preference coverage, identity acceptance and audit review. | -| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Inbox, activity, mailbox-health and FAQ-history timestamps use the saved hotel timezone; finish the remaining secondary screens and agreed desktop-parity acceptance. | +| 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. | | 18 | Pilot, capacity, and release approval | B/C | In progress | A bounded read-only sandbox capacity probe, machine-validated pilot decision and Gate B/C incident-exercise record are implemented. Agree targets, run the probe with host monitoring, complete the supervised pilot and incident exercises, resolve or explicitly contain findings, and retain separate hotel-owner and technical go/no-go decisions. | ## Delivery sequence diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 44e5269..e0a5355 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ The reviewed candidate is now promoted into the local `main` history. It is not - Approval-controlled OHIP PMS and NMI payment workflows. - FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery. - No-send FAQ batch evaluation with false-positive and false-negative reporting before activation. -- Stable tenant-scoped inbox pagination beyond the former 500-message view, stronger unsaved-draft navigation guards, and hotel-timezone inbox timestamps. +- Stable tenant-scoped inbox pagination beyond the former 500-message view, unsaved-draft guards including browser history navigation, consistent hotel-timezone timestamps across operational screens, and a release-bound desktop-parity acceptance record. - A bounded read-only sandbox capacity probe plus machine-validated, release-bound supervised-pilot and incident-exercise records. - Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling. @@ -19,7 +19,7 @@ These capabilities still require their separately documented provider, host and ### Known limitations and launch conditions - Gate A still requires a successful default-branch CI run, durable off-host release archive, target-Debian deployment, persistent storage/key validation, monitoring, and a successful restore/rollback exercise. -- Gate B still requires real Google acceptance and supervised staff testing. Before pilot use, safely paginate beyond the 500-conversation limit, protect drafts across every navigation path, make login throttling proxy-aware, and render dates in the saved hotel timezone—or record and approve explicit operational containment. +- Gate B still requires real Google acceptance and supervised staff testing, including desktop-parity acceptance of pagination, draft protection, proxy-aware login throttling and saved-hotel-timezone rendering. - Gate C still requires the Rezlynx/Guestline adapter and independently accepted PMS/payment workflows, plus privacy, identity, capacity, and release approvals. - FAQ live mode and all external write actions must remain disabled until their corresponding acceptance gate has passed. diff --git a/deploy/desktop-acceptance.example.json b/deploy/desktop-acceptance.example.json new file mode 100644 index 0000000..137caff --- /dev/null +++ b/deploy/desktop-acceptance.example.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "system": "guestops-desktop-parity", + "dataClassification": "synthetic-only", + "desktopBaselineCommit": "18b983bf402ecdded6430fd40bc4d3320587595a", + "releaseCommit": "0000000000000000000000000000000000000000", + "releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "environment": "https://sandbox-guestops.example.invalid", + "browser": { + "name": "Microsoft Edge", + "version": "REPLACE_WITH_FULL_VERSION", + "operatingSystem": "Windows 11" + }, + "viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1}, + "hotelTimeZone": "Europe/London", + "operator": "Acceptance operator", + "reviewedBy": "Independent reviewer", + "startedAt": "2026-09-29T09:00:00Z", + "endedAt": "2026-09-29T10:00:00Z", + "reviewedAt": "2026-09-29T11:00:00Z", + "scenarios": [ + {"id": "desktop-layout", "status": "not-run", "evidence": []}, + {"id": "draft-conversation-guard", "status": "not-run", "evidence": []}, + {"id": "draft-filter-search-guard", "status": "not-run", "evidence": []}, + {"id": "draft-route-history-reload-guard", "status": "not-run", "evidence": []}, + {"id": "inbox-core-workflow", "status": "not-run", "evidence": []}, + {"id": "pagination-beyond-500", "status": "not-run", "evidence": []}, + {"id": "role-and-control-parity", "status": "not-run", "evidence": []}, + {"id": "timezone-and-dst", "status": "not-run", "evidence": []} + ] +} diff --git a/deploy/desktop_acceptance.py b/deploy/desktop_acceptance.py new file mode 100644 index 0000000..15210c5 --- /dev/null +++ b/deploy/desktop_acceptance.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Validate a restricted GuestOps desktop-parity acceptance record.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +import re +from urllib.parse import urlparse + + +DESKTOP_BASELINE = "18b983bf402ecdded6430fd40bc4d3320587595a" +SCENARIOS = { + "inbox-core-workflow", + "pagination-beyond-500", + "draft-conversation-guard", + "draft-filter-search-guard", + "draft-route-history-reload-guard", + "timezone-and-dst", + "role-and-control-parity", + "desktop-layout", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def timestamp(value: object, field: str) -> dt.datetime: + require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.") + try: + parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as error: + raise ValueError(f"{field} is not a valid timestamp.") from error + require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.") + return parsed + + +def safe_name(value: object, field: str) -> str: + name = str(value or "").strip() + require(2 <= len(name) <= 120 and "@" not in name, f"{field} requires a name without an email address.") + return name + + +def validate(record: object) -> None: + require(isinstance(record, dict), "Acceptance record must be a JSON object.") + require(record.get("schemaVersion") == 1, "Unsupported acceptance record schema.") + require(record.get("system") == "guestops-desktop-parity", + "Acceptance record system must be guestops-desktop-parity.") + require(record.get("dataClassification") == "synthetic-only", + "Desktop acceptance must use synthetic data only.") + require(record.get("desktopBaselineCommit") == DESKTOP_BASELINE, + "desktopBaselineCommit must identify the reviewed desktop baseline.") + require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None, + "releaseCommit must be a full lowercase Git SHA.") + require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None, + "releaseRecordSha256 must be a SHA-256 digest.") + + origin = urlparse(str(record.get("environment", ""))) + require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/") + and not origin.query and not origin.fragment and origin.username is None and origin.password is None, + "environment must be an HTTPS origin without credentials, path, query or fragment.") + + browser = record.get("browser") + require(isinstance(browser, dict) and set(browser) == {"name", "version", "operatingSystem"}, + "browser must contain exactly name, version and operatingSystem.") + for field in ("name", "version", "operatingSystem"): + require(2 <= len(str(browser.get(field, "")).strip()) <= 120, + f"browser.{field} is required.") + viewport = record.get("viewport") + require(isinstance(viewport, dict) and set(viewport) == {"width", "height", "deviceScaleFactor"}, + "viewport must contain exactly width, height and deviceScaleFactor.") + require(isinstance(viewport["width"], int) and not isinstance(viewport["width"], bool) + and 1280 <= viewport["width"] <= 7680, "Desktop viewport width must be between 1280 and 7680 pixels.") + require(isinstance(viewport["height"], int) and not isinstance(viewport["height"], bool) + and 720 <= viewport["height"] <= 4320, "Desktop viewport height must be between 720 and 4320 pixels.") + require(isinstance(viewport["deviceScaleFactor"], (int, float)) and not isinstance(viewport["deviceScaleFactor"], bool) + and 0.5 <= viewport["deviceScaleFactor"] <= 4, "deviceScaleFactor must be between 0.5 and 4.") + time_zone = str(record.get("hotelTimeZone", "")) + require(time_zone == "UTC" or re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+\-]+)+", time_zone) is not None, + "hotelTimeZone must be UTC or an IANA timezone name.") + + operator = safe_name(record.get("operator"), "operator") + reviewer = safe_name(record.get("reviewedBy"), "reviewedBy") + require(operator.casefold() != reviewer.casefold(), "operator and reviewedBy must be different people.") + started = timestamp(record.get("startedAt"), "startedAt") + ended = timestamp(record.get("endedAt"), "endedAt") + reviewed = timestamp(record.get("reviewedAt"), "reviewedAt") + require(started <= ended <= reviewed, "Acceptance timestamps are out of order.") + + scenarios = record.get("scenarios") + require(isinstance(scenarios, list), "scenarios must be a list.") + ids = [item.get("id") for item in scenarios if isinstance(item, dict)] + require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == SCENARIOS, + "Acceptance record requires the exact desktop scenario set.") + for item in scenarios: + scenario_id = item["id"] + require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.") + evidence = item.get("evidence") + require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all( + isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in evidence + ), f"Scenario {scenario_id} requires safe opaque evidence references without email addresses.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("record", type=Path) + args = parser.parse_args() + validate(json.loads(args.record.read_text(encoding="utf-8"))) + print(f"Desktop-parity acceptance record is structurally complete: {len(SCENARIOS)} scenarios passed. This validates the record, not its restricted evidence.") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Desktop-parity acceptance record rejected: {error}", file=__import__("sys").stderr) + raise SystemExit(1) diff --git a/docs/desktop-acceptance.md b/docs/desktop-acceptance.md new file mode 100644 index 0000000..c4190a0 --- /dev/null +++ b/docs/desktop-acceptance.md @@ -0,0 +1,28 @@ +# Desktop-parity acceptance + +Run this supervised exercise against the exact HTTPS sandbox release using synthetic conversations and accounts. Compare the web workflow with the reviewed desktop baseline `18b983bf402ecdded6430fd40bc4d3320587595a`; this is workflow and safety parity, not a claim that the interfaces are visually identical. + +Use the browser and workstation configuration intended for the pilot. Record its full browser version, operating system, viewport, scale factor and the hotel's saved IANA timezone. Keep screenshots and recordings in the restricted acceptance store because UI evidence may contain message text or account details. The operator and independent reviewer must be different people. + +## Required scenarios + +| Record ID | Exercise | Passing result | +| --- | --- | --- | +| `inbox-core-workflow` | Open a synthetic conversation, create and save a draft, use an approved answer, change status and reload. | The same conversation and saved state remain available, and no action sends mail. | +| `pagination-beyond-500` | Seed more than 500 tenant-scoped conversations and load successive 50-item pages while another conversation is inserted. | Every original record can be reached once without cross-hotel records, duplicates or skipped records. | +| `draft-conversation-guard` | Edit without saving, select another conversation, reject the discard prompt, then accept it. | Rejection retains the draft and selection; acceptance changes conversation and discards only the unsaved edit. | +| `draft-filter-search-guard` | Repeat the reject/accept checks while changing status filters and search text. | Rejection preserves the edit and prior view; acceptance applies the requested view change. | +| `draft-route-history-reload-guard` | With an unsaved edit, try sidebar navigation, browser Back/Forward and reload or tab close. | In-app navigation and browser history require confirmation; reload or close raises the browser's native unsaved-change warning. | +| `timezone-and-dst` | Choose a timezone different from the workstation and inspect inbox, activity, health, mailbox, automation, PMS, payment and team-link times, including values around a daylight-saving transition. | Every operational timestamp follows the saved hotel timezone and represents the same instant consistently. | +| `role-and-control-parity` | Exercise owner and staff accounts with provider writes and automation disabled. | Staff cannot access owner functions, and neither role can bypass feature, review or provider controls. | +| `desktop-layout` | Complete the workflow at the recorded desktop viewport and scale, including keyboard navigation and browser zoom checks agreed for the pilot. | Primary controls remain visible and usable without clipped dialogs, overlapping content or an inaccessible action. | + +## Record and validation + +Copy `deploy/desktop-acceptance.example.json` into the restricted acceptance store. Replace its release identifiers, environment, workstation details, timestamps and people. Mark each scenario `pass` only after the independent reviewer has checked its evidence. The example is intentionally invalid while scenarios are `not-run`. + +```sh +python3 deploy/desktop_acceptance.py /secure/acceptance/desktop-acceptance.json +``` + +The validator checks structure, release and baseline binding, desktop dimensions, independent review, complete passing scenarios and opaque evidence references. It cannot inspect screenshots or prove browser behavior. Retain the validated record and its checksum, then reference it from `inbox-usability` in the pilot approval record. diff --git a/docs/pilot-release.md b/docs/pilot-release.md index 5943e36..51729e6 100644 --- a/docs/pilot-release.md +++ b/docs/pilot-release.md @@ -48,3 +48,5 @@ python3 deploy/pilot_approval.py /secure/acceptance/pilot-approval.json The validator requires the exact Gate B evidence set, or that set plus independently accepted PMS and payment-provider evidence for Gate C. It also verifies that observed concurrency meets the pre-agreed target and that p95 latency and error rate remain within their pre-agreed bounds. Structural validation does not inspect evidence or authorize rollout by itself. Complete the [incident and rollback exercise](incident-exercise.md) before marking `incident-support` as passed. Its record must use the same release identifiers and target gate as this decision. Reference the retained exercise record and validator output; do not substitute a local automated-test result for the supervised exercise. + +Complete the [desktop-parity acceptance exercise](desktop-acceptance.md) before marking `inbox-usability` as passed. Bind it to the same release identifiers and retain its independently reviewed record outside the repository. diff --git a/tests/test_desktop_acceptance.py b/tests/test_desktop_acceptance.py new file mode 100644 index 0000000..86dadc8 --- /dev/null +++ b/tests/test_desktop_acceptance.py @@ -0,0 +1,65 @@ +import importlib.util +from pathlib import Path +import unittest + + +spec = importlib.util.spec_from_file_location("desktop_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "desktop_acceptance.py") +acceptance = importlib.util.module_from_spec(spec) +spec.loader.exec_module(acceptance) + + +def valid_record(): + return { + "schemaVersion": 1, + "system": "guestops-desktop-parity", + "dataClassification": "synthetic-only", + "desktopBaselineCommit": acceptance.DESKTOP_BASELINE, + "releaseCommit": "a" * 40, + "releaseRecordSha256": "b" * 64, + "environment": "https://sandbox-guestops.futuresens.co.uk", + "browser": {"name": "Microsoft Edge", "version": "140.0.0.0", "operatingSystem": "Windows 11"}, + "viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1}, + "hotelTimeZone": "Europe/London", + "operator": "Acceptance operator", + "reviewedBy": "Independent reviewer", + "startedAt": "2026-09-29T09:00:00Z", + "endedAt": "2026-09-29T10:00:00Z", + "reviewedAt": "2026-09-29T11:00:00Z", + "scenarios": [ + {"id": scenario, "status": "pass", "evidence": [f"restricted-ticket-{index}"]} + for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1) + ], + } + + +class DesktopAcceptanceTests(unittest.TestCase): + def test_complete_record_passes(self): + acceptance.validate(valid_record()) + + def test_exact_scenarios_and_desktop_baseline_are_required(self): + record = valid_record();record["scenarios"].pop() + with self.assertRaisesRegex(ValueError, "exact desktop scenario"): + acceptance.validate(record) + record = valid_record();record["desktopBaselineCommit"] = "c" * 40 + with self.assertRaisesRegex(ValueError, "reviewed desktop baseline"): + acceptance.validate(record) + + def test_desktop_viewport_and_timezone_are_required(self): + record = valid_record();record["viewport"]["width"] = 1024 + with self.assertRaisesRegex(ValueError, "Desktop viewport width"): + acceptance.validate(record) + record = valid_record();record["hotelTimeZone"] = "local browser time" + with self.assertRaisesRegex(ValueError, "IANA timezone"): + acceptance.validate(record) + + def test_independent_review_and_safe_evidence_are_required(self): + record = valid_record();record["reviewedBy"] = record["operator"] + with self.assertRaisesRegex(ValueError, "different people"): + acceptance.validate(record) + record = valid_record();record["scenarios"][0]["evidence"] = ["guest@example.invalid"] + with self.assertRaisesRegex(ValueError, "safe opaque"): + acceptance.validate(record) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/PaymentsPage.tsx b/web/src/PaymentsPage.tsx index 148c91d..d985159 100644 --- a/web/src/PaymentsPage.tsx +++ b/web/src/PaymentsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { api, type Hotel } from './api'; +import { hotelTime } from './time'; type Payment={id:string;reference:string;email:string;description:string;amount:number;currency:string;state:string;detail:string;invoiceId:string;version:number;expiresAt:string;updatedAt:string;checkedAt:string|null}; type Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean}; type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise)=>Promise;onHotel:(h:Hotel)=>void}; @@ -19,7 +20,7 @@ export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){

Payment connection

{connection?.preview?'Sample workspace: no invoices, emails or payments can be created.':connection?.configured?`NMI ${connection.sandbox?'sandbox':'production'} configuration is available.`:'Your administrator needs to configure this hotel’s NMI merchant account.'}

GuestOps hotel ID: {hotel.id}

Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.

Prepare a payment request

Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.

Payment history

{items.length===0&&

No payment requests yet.

}
{items.map(p=>)}
- {current&&

{current.state==='Review'?'Review this payment request':'Payment request status'}

Reference
{current.reference}
Customer
{current.email}
Amount
{current.currency} {current.amount.toFixed(2)}
Description
{current.description}
NMI invoice
{current.invoiceId||'Not confirmed'}
Last verified
{current.checkedAt?new Date(current.checkedAt).toLocaleString():'Not yet verified'}

{current.state==='Paid'?'Paid · reported by NMI':current.state} — {current.detail}

+ {current&&

{current.state==='Review'?'Review this payment request':'Payment request status'}

Reference
{current.reference}
Customer
{current.email}
Amount
{current.currency} {current.amount.toFixed(2)}
Description
{current.description}
NMI invoice
{current.invoiceId||'Not confirmed'}
Last verified
{current.checkedAt?hotelTime(current.checkedAt,hotel.timezone):'Not yet verified'}

{current.state==='Paid'?'Paid · reported by NMI':current.state} — {current.detail}

{current.state==='Review'?<>
Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.

Approval expires after fifteen minutes. A payment reference cannot be reused.

:!['Cancelled','NotCreated'].includes(current.state)&&<>

This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.

}

A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.

} diff --git a/web/src/PmsPage.tsx b/web/src/PmsPage.tsx index 1d51d2e..f2cb844 100644 --- a/web/src/PmsPage.tsx +++ b/web/src/PmsPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { api, type Hotel } from './api'; +import { hotelTime } from './time'; type Snapshot={id:string;reservationId:string;confirmation:string;guestName:string;arrival:string;departure:string;status:string;roomType:string;total:string;fetchedAt:string}; type Change={id:string;reservationId:string;kind:string;arrival:string;departure:string;note:string;state:string;detail:string;version:number;updatedAt:string;expiresAt:string;before:Snapshot;after:Snapshot|null}; type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean}; @@ -21,7 +22,7 @@ export function PmsPage({hotel,owner,busy,run,onHotel}:Props){

OHIP connection

{connection?.configured?`Connected configuration for property ${connection.hotelCode}. Lookup will verify access.`:connection?.preview?'Sample workspace: real PMS lookup and updates are disabled.':'Your administrator needs to configure this hotel’s OHIP connection.'}

GuestOps hotel ID: {hotel.id}

Lookup is available separately. Every change needs review; automatic PMS updates are off.

Find a reservation

{snapshot&&<>
{kind==='AddNote'?