Compare commits

...

14 Commits

Author SHA1 Message Date
wolf-demon
a3ef408de6 Prepare 0.2.0 Gate B pilot candidate
Some checks failed
Build and verify web migration / verify (push) Has been cancelled
2026-09-29 21:17:40 +01:00
wolf-demon
aa3436fd1d mileztone17 2026-09-29 21:03:17 +01:00
wolf-demon
4dd33c90e1 milestone 19 &20
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
2026-09-29 20:50:40 +01:00
wolf-demon
2e8cf269fb miledtone 17
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
2026-09-29 20:44:39 +01:00
wolf-demon
92f875621d milestone 16 complete
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
2026-09-29 20:40:18 +01:00
wolf-demon
47178a64ce Add stable inbox cursor pagination 2026-09-29 19:28:26 +01:00
wolf-demon
d1e27c0e47 Add no-send FAQ activation evaluation 2026-09-29 19:23:05 +01:00
wolf-demon
b29d63d413 Add Google mailbox acceptance evidence workflow 2026-09-29 19:18:43 +01:00
wolf-demon
c5ace6b63f Add scheduled encrypted backup operations 2026-09-29 18:53:35 +01:00
wolf-demon
ede39ec892 Add Debian persistence acceptance drill 2026-09-29 18:51:56 +01:00
wolf-demon
e843d7f292 Update milestone tracking after candidate promotion 2026-09-29 18:16:19 +01:00
wolf-demon
aba4773cba Promote release candidate and add release evidence 2026-09-29 18:13:55 +01:00
wolf-demon
4f51bff3d0 initial release from codex
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
2026-09-29 16:56:52 +01:00
wolf-demon
5c4eadaf97
Merge pull request #1 from wolf-demon/codex/web-foundation
Some checks are pending
Build and verify web migration / verify (push) Waiting to run
Build the GuestOps web foundation for Linux and MongoDB
2026-09-09 14:44:25 +01:00
64 changed files with 2667 additions and 50 deletions

View File

@ -2,6 +2,7 @@ name: Build and verify web migration
on: on:
push: push:
branches: [main, 'codex/**'] branches: [main, 'codex/**']
tags: ['[0-9]+.[0-9]+.[0-9]+']
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -25,7 +26,7 @@ jobs:
- name: Build services - name: Build services
run: dotnet build src/GuestOps.Worker/GuestOps.Worker.csproj -c Release run: dotnet build src/GuestOps.Worker/GuestOps.Worker.csproj -c Release
- name: Verify backup validation and failure recovery - name: Verify backup validation and failure recovery
run: python3 -m unittest discover -s tests -p test_ops.py run: python3 -m unittest discover -s tests -p 'test_*.py'
- name: Build interface - name: Build interface
working-directory: web working-directory: web
run: npm ci && npm run build run: npm ci && npm run build
@ -46,7 +47,17 @@ jobs:
docker build --target worker -t guestops-worker:${{ github.sha }} . docker build --target worker -t guestops-worker:${{ github.sha }} .
- name: Package reviewed images - name: Package reviewed images
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
run: docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip > guestops-images.tar.gz run: |
docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip -n > guestops-images.tar.gz
python3 deploy/release_record.py \
--artifact guestops-images.tar.gz \
--commit '${{ github.sha }}' \
--api-image 'guestops-api:${{ github.sha }}' \
--api-id "$(docker image inspect --format '{{.Id}}' 'guestops-api:${{ github.sha }}')" \
--worker-image 'guestops-worker:${{ github.sha }}' \
--worker-id "$(docker image inspect --format '{{.Id}}' 'guestops-worker:${{ github.sha }}')" \
--output release-record.json
sha256sum --check <(python3 -c "import json; r=json.load(open('release-record.json')); print(r['artifact']['sha256'] + ' ' + r['artifact']['name'])")
- name: Smoke test production containers and restart persistence - name: Smoke test production containers and restart persistence
env: env:
GUESTOPS_API_IMAGE: guestops-api:${{ github.sha }} GUESTOPS_API_IMAGE: guestops-api:${{ github.sha }}
@ -81,5 +92,7 @@ jobs:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
name: guestops-linux-${{ github.run_number }} name: guestops-linux-${{ github.run_number }}
path: guestops-images.tar.gz path: |
retention-days: 7 guestops-images.tar.gz
release-record.json
retention-days: 90

4
.gitignore vendored
View File

@ -20,4 +20,6 @@
guestops-backup-*/ guestops-backup-*/
guestops-restore-*/ guestops-restore-*/
*.tar.gpg *.tar.gpg
__pycache__/ __pycache__/
.fake

36
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,36 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "GuestOps API (Preview)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-api",
"program": "${workspaceFolder}/src/GuestOps.Api/bin/Debug/net10.0/GuestOps.Api.dll",
"cwd": "${workspaceFolder}/src/GuestOps.Api",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"Preview": "true",
"ASPNETCORE_URLS": "http://127.0.0.1:5180"
},
"stopAtEntry": false
},
{
"name": "GuestOps Web (Vite)",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"cwd": "${workspaceFolder}/web"
}
],
"compounds": [
{
"name": "GuestOps: API + Web",
"configurations": [
"GuestOps API (Preview)",
"GuestOps Web (Vite)"
],
"stopAll": true
}
]
}

15
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-api",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/src/GuestOps.Api/GuestOps.Api.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

View File

@ -1 +1,9 @@
<Project><PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><TreatWarningsAsErrors>true</TreatWarningsAsErrors></PropertyGroup></Project> <Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>0.2.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

69
MILESTONES.md Normal file
View File

@ -0,0 +1,69 @@
# GuestOps Milestone Report
Version: **0.2.0 release candidate**
Last updated: **29 September 2026**
This is the working delivery tracker for GuestOps Web. Update a milestone when its state changes and link the pull request, release artifact, test run, or acceptance record that proves the change.
## Status key
- **Implemented** — present on `main` and supported by code or automated-test evidence.
- **In progress** — repository or environment work has started but an exit condition remains open.
- **Acceptance required** — implemented in code but still requires a real provider, Debian host, or operational exercise.
- **Planned** — work is not yet complete.
- **Deferred** — intentionally outside the current release gate.
## Release gates
| Gate | Outcome | Current assessment | Exit condition |
| --- | --- | --- | --- |
| A | Website operational | Not yet approved | Reproducible release on the target Debian host, persistent data/keys, HTTPS, monitoring, and a successful backup/restore drill. |
| B | Supervised hotel pilot | Not yet approved | Gate A plus real Google acceptance, staff workflow acceptance, controlled AI/FAQ activation, and closure or explicit containment of pilot usability and security findings. |
| C | Integrated rollout | Not yet approved | Gate B plus independently accepted PMS and payment integrations, identity/privacy controls, capacity evidence, and formal release approval. |
## Delivery milestones
| # | Milestone | Gate | Status | Evidence and remaining work |
| ---: | --- | :---: | --- | --- |
| 1 | Web foundation | A | Implemented | The current `main` branch provides the React workspace, ASP.NET Core API, tenant-scoped MongoDB access, read-only Google import foundation, preview mode, container definitions, and automated tests. Live provider and host acceptance still apply. |
| 2 | AI suggestions and reviewed Gmail sending | B | Implemented / acceptance required | Promoted to local `main`; verify real mailbox threading, reconnect/revocation, duplicate-send prevention, uncertain outcomes, and staff review before pilot use. |
| 3 | OHIP PMS workflow | C | Implemented / acceptance required | Proposal, approval, and execution controls are promoted to local `main`. Provider sandbox and contract-level acceptance remain independent requirements. |
| 4 | NMI payment workflow | C | Implemented / acceptance required | Payment proposal and approval controls are promoted to local `main`. Sandbox acceptance, reconciliation, expiry, and ambiguous-result recovery remain required. |
| 5 | FAQ automation | B | Implemented / acceptance required | Draft and approval controls are promoted to local `main`. Keep live automation disabled until knowledge quality, thresholds, and rollback behaviour pass acceptance. |
| 6 | Team onboarding and account recovery | B | Implemented / acceptance required | Invitation, password reset, and recovery flows are promoted to local `main`; verify deployed links, mail delivery, token expiry, and administrator recovery procedures. |
| 7 | Google connection recovery | B | Implemented / acceptance required | Connection epochs, checkpoint recovery, and revocation handling are promoted to local `main`; complete real Google acceptance and worker-restart exercises. |
| 8 | Operational readiness tooling | A | Implemented / acceptance required | Backup, restore, release, and diagnostic tooling is promoted to local `main`; execute it on the actual Debian host and retain evidence. |
| 9 | Gitea and reproducible releases | A | In progress | The `0.2.0` candidate is versioned on `main`. CI records the full commit, matched application version, archive checksum and immutable image IDs, and the rollback procedure is documented. Retain the successful default-branch evidence off-host and create the immutable approval tag only after Gate B approval; the existing `0.1.0` tag remains attached to the foundation release. |
| 10 | Debian deployment and persistence | A | In progress | Compose uses separate named database and shared key volumes, private host configuration, loopback-only API access and bounded logs. The confirmation-gated persistence drill verifies restart and container-recreation behaviour. Run it on the provisioned Debian host, complete HTTPS and controlled-reboot acceptance, and retain the evidence. |
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling without command-line secrets. Install and test it on Debian, configure monitored off-host transfer and durable logs, name alert/retention owners, and retain evidence from a timed restore and recovery drill. |
| 12 | Google mailbox and reviewed-reply acceptance | B | In progress | The synthetic-data provider runbook, exact scenario set and restricted-record validator are implemented. Complete every scenario against the accepted Debian release and dedicated Google sandbox accounts, independently review the evidence, and retain the validated record. |
| 13 | Rezlynx/Guestline adapter | C | Planned | Obtain the provider contract and sandbox, implement the adapter and mapping, and accept idempotency, stale-data, ambiguous-write, and reconciliation paths. |
| 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 | Implemented / acceptance required | Owners can run a bounded no-send batch evaluation, and a release-bound acceptance record enforces positive/negative coverage, zero FAQ errors, separate AI review, staff training, stop-control evidence and named monitoring/rollback owners. Complete the supervised evaluation and retain independent approval. |
| 16 | Identity, preferences, and privacy | B/C | Implemented / acceptance required | Login throttling trusts the client address only after one-hop processing by the configured proxy. A release-bound review now covers owner-controlled preferences, account/session controls, data inventory, retention/deletion/legal-hold ownership, provider decisions, audit evidence and known identity limitations. Complete the legal/operational decisions and independently approve the record. |
| 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 | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Push and retain CI evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
## Delivery sequence
The current critical path is:
`9 → 10 → 11 → 12 → 15 → 18`
Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel provider tracks. They do not need to delay a Google-only supervised pilot, but both remain independently gated before Gate C.
## Next actions
- [ ] Push the local release-candidate promotion to the intended default branch and retain its successful CI evidence.
- [ ] Retain successful `0.2.0` default-branch CI evidence, then create and archive the immutable approval tag only after Gate B approval.
- [ ] Deploy to the target Debian environment with persistent MongoDB and data-protection keys.
- [ ] Run and record backup, restore, restart, monitoring, and rollback exercises.
- [ ] Complete real Google mailbox acceptance without using production guest data.
- [ ] Resolve or explicitly contain the milestone 16–17 pilot findings listed above.
- [ ] Obtain the Guestline/Rezlynx interface contract and sandbox access.
- [ ] Agree the payment-provider acceptance and reconciliation plan.
- [ ] Capture named owners and target dates for milestones 9–18.
## Tracking convention
For every status update, add the owner, target or completion date, and evidence link to the relevant row or to an issue referenced from that row. A milestone is not complete solely because code exists: provider and host acceptance must be recorded wherever the status says **Acceptance required**.

View File

@ -2,6 +2,10 @@
A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending, reviewed OHIP reservation updates, NMI hosted invoices, controlled FAQ auto-replies and team onboarding. It is not yet a production-complete replacement.** A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending, reviewed OHIP reservation updates, NMI hosted invoices, controlled FAQ auto-replies and team onboarding. It is not yet a production-complete replacement.**
Current release-candidate version: **0.2.0**
Project progress is tracked in the [milestone report](MILESTONES.md). User-visible changes and release limitations are recorded in the [release notes](RELEASE_NOTES.md).
## Implemented so far ## Implemented so far
- Responsive React inbox, message search and status filters, editable saved drafts, approved hotel answers, activity history and hotel settings. - Responsive React inbox, message search and status filters, editable saved drafts, approved hotel answers, activity history and hotel settings.

44
RELEASE_NOTES.md Normal file
View File

@ -0,0 +1,44 @@
# GuestOps Release Notes
## 0.2.0 — Gate B release candidate
This candidate freezes the implemented Gate B scope for controlled acceptance. It is not yet approved for live hotel operations and does not become a release until the exact commit is pushed, CI and operational evidence are retained, the supervised pilot is approved and the immutable `0.2.0` tag is created.
### Promoted scope
- AI-assisted reply suggestions and staff-reviewed Gmail sending.
- 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.
- Release-bound automation and identity/privacy acceptance records covering training, provider decisions, retention ownership and known limitations.
- 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 run, go/no-go and incident-exercise records.
- Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling.
These capabilities still require their separately documented provider, host and operational acceptance. Google, PMS and payment-provider acceptance is not established by local automated tests.
### 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, 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.
See [MILESTONES.md](MILESTONES.md) for the gate assessment, delivery sequence, and remaining work.
## 0.1.0 — 29 September 2026
The `0.1.0` tag identifies the initial GuestOps Web foundation. It is not approved for live hotel operations.
### Foundation scope
- Responsive shared inbox, search and status filters, saved reply drafts, approved hotel answers, activity history, and hotel settings.
- ASP.NET Core authentication with protected cookies, password hashing, CSRF validation, login throttling, role checks, and server-derived hotel membership.
- Tenant-scoped MongoDB storage with optimistic concurrency, unique mailbox/message indexes, OAuth state expiry, and worker leases.
- Read-only Google OAuth and recent-message import foundation with checkpoint and duplicate protection.
- Preview mode, Linux container definitions, Nginx HTTPS example, and automated backend/frontend verification.
- Live email sending, PMS writes, payment workflows, and automatic FAQ replies were disabled in this foundation.
### Versioning
The foundation remains tagged `0.1.0`. The .NET projects and frontend package now share candidate version `0.2.0`; create that immutable tag only after the exact commit, checksummed artifacts and Gate B acceptance evidence have been approved.

View File

@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"system": "guestops-automation-acceptance",
"targetGate": "B",
"dataClassification": "synthetic-only",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.example.invalid",
"operator": "REPLACE OPERATOR",
"reviewedBy": "REPLACE REVIEWER",
"startedAt": "2026-10-01T09:00:00Z",
"endedAt": "2026-10-01T10:00:00Z",
"reviewedAt": "2026-10-01T11:00:00Z",
"faqEvaluation": {"positiveCases": 0, "negativeCases": 0, "falsePositives": 0, "falseNegatives": 0, "reportSha256": "0000000000000000000000000000000000000000000000000000000000000000"},
"aiEvaluation": {"casesReviewed": 0, "unsafeDraftsApproved": 0, "reportSha256": "0000000000000000000000000000000000000000000000000000000000000000"},
"staffTrained": 0,
"monitoringOwner": "REPLACE",
"rollbackOwner": "REPLACE",
"scenarios": [
{"id": "ai-suggestion-review", "status": "not-run", "evidence": []},
{"id": "faq-positive-negative", "status": "not-run", "evidence": []},
{"id": "faq-stop-control", "status": "not-run", "evidence": []},
{"id": "knowledge-curation", "status": "not-run", "evidence": []},
{"id": "monitoring-rollback", "status": "not-run", "evidence": []},
{"id": "staff-training", "status": "not-run", "evidence": []}
],
"postAcceptanceState": {"faqMode": "off", "pmsWrites": "disabled", "paymentCreation": "disabled"}
}

View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Validate restricted Gate B knowledge, AI and FAQ acceptance evidence."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
SCENARIOS = {
"knowledge-curation", "faq-positive-negative", "faq-stop-control",
"ai-suggestion-review", "staff-training", "monitoring-rollback",
}
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:
return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
def name(value: object, field: str) -> str:
result = str(value or "").strip()
require(2 <= len(result) <= 120 and "@" not in result, f"{field} requires a name without an email address.")
return result
def refs(value: object, field: str) -> None:
require(isinstance(value, list) and 1 <= len(value) <= 10 and all(
isinstance(item, str) and 3 <= len(item) <= 200 and "@" not in item for item in value
), f"{field} requires safe opaque evidence references.")
def validate(record: object) -> None:
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported automation acceptance schema.")
require(record.get("system") == "guestops-automation-acceptance", "system must be guestops-automation-acceptance.")
require(record.get("targetGate") == "B", "Automation acceptance must target Gate B.")
require(record.get("dataClassification") == "synthetic-only", "Automation acceptance must use synthetic data only.")
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.")
operator = name(record.get("operator"), "operator")
reviewer = 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.")
faq = record.get("faqEvaluation")
require(isinstance(faq, dict), "faqEvaluation is required.")
for field in ("positiveCases", "negativeCases"):
require(isinstance(faq.get(field), int) and not isinstance(faq.get(field), bool) and faq[field] > 0,
f"faqEvaluation.{field} must be a positive integer.")
require(faq.get("falsePositives") == 0 and faq.get("falseNegatives") == 0,
"FAQ activation requires zero false positives and zero false negatives.")
require(re.fullmatch(r"[0-9a-f]{64}", str(faq.get("reportSha256", ""))) is not None,
"faqEvaluation.reportSha256 must identify the retained report.")
ai = record.get("aiEvaluation")
require(isinstance(ai, dict) and isinstance(ai.get("casesReviewed"), int) and not isinstance(ai.get("casesReviewed"), bool) and ai["casesReviewed"] > 0,
"aiEvaluation requires at least one reviewed case.")
require(isinstance(ai.get("unsafeDraftsApproved"), int) and not isinstance(ai.get("unsafeDraftsApproved"), bool)
and ai["unsafeDraftsApproved"] == 0, "No unsafe AI draft may be approved.")
require(re.fullmatch(r"[0-9a-f]{64}", str(ai.get("reportSha256", ""))) is not None,
"aiEvaluation.reportSha256 must identify the retained report.")
require(isinstance(record.get("staffTrained"), int) and not isinstance(record.get("staffTrained"), bool) and record["staffTrained"] > 0,
"At least one pilot staff member must complete training.")
name(record.get("monitoringOwner"), "monitoringOwner")
name(record.get("rollbackOwner"), "rollbackOwner")
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 automation scenario set.")
for item in scenarios:
require(item.get("status") == "pass", f"Scenario {item['id']} has not passed.")
refs(item.get("evidence"), f"Scenario {item['id']}")
require(record.get("postAcceptanceState") == {"faqMode": "off", "pmsWrites": "disabled", "paymentCreation": "disabled"},
"Acceptance must end with FAQ live mode, PMS writes and payment creation disabled.")
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("Automation acceptance record is structurally complete. This validates the record, not its restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Automation acceptance record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

128
deploy/capacity_probe.py Normal file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Run a bounded, read-only capacity probe against an approved GuestOps sandbox."""
from __future__ import annotations
import argparse
import concurrent.futures
import datetime as dt
import http.cookiejar
import json
import os
from pathlib import Path
import re
import statistics
import time
import urllib.error
import urllib.parse
import urllib.request
PATHS = ("/health/ready", "/api/hotel", "/api/conversations/page")
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def percentile(values: list[float], fraction: float) -> float:
ordered = sorted(values)
position = max(0, min(len(ordered) - 1, int(len(ordered) * fraction + 0.999999) - 1))
return ordered[position]
def summarize(results: list[tuple[bool, float]], concurrency: int) -> dict[str, object]:
require(len(results) > 0, "At least one probe result is required.")
latencies = [latency for _, latency in results]
successes = sum(1 for success, _ in results if success)
return {
"concurrency": concurrency,
"requests": len(results),
"successes": successes,
"failures": len(results) - successes,
"errorRate": round((len(results) - successes) / len(results), 6),
"latencyMs": {
"median": round(statistics.median(latencies), 2),
"p95": round(percentile(latencies, 0.95), 2),
"maximum": round(max(latencies), 2),
},
}
def login(origin: str, email: str, password: str) -> str:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
with opener.open(origin + "/api/session", timeout=15) as response:
csrf = json.load(response)["csrfToken"]
body = json.dumps({"email": email, "password": password}).encode()
request = urllib.request.Request(origin + "/api/auth/login", data=body, method="POST", headers={"Content-Type": "application/json", "X-CSRF-TOKEN": csrf})
with opener.open(request, timeout=15) as response:
require(response.status == 200, "Sandbox login failed.")
cookies = "; ".join(f"{cookie.name}={cookie.value}" for cookie in jar)
require("guestops.session=" in cookies, "Sandbox did not issue a GuestOps session cookie.")
return cookies
def request_once(origin: str, cookie: str, number: int) -> tuple[bool, float]:
path = PATHS[number % len(PATHS)]
request = urllib.request.Request(origin + path, headers={"Cookie": cookie, "Accept": "application/json"})
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=20) as response:
success = response.status == 200
response.read(1024) # Bound local processing; never retain response or guest content.
except (OSError, urllib.error.HTTPError):
success = False
return success, (time.perf_counter() - started) * 1000
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--origin", required=True)
parser.add_argument("--requests", type=int, default=100)
parser.add_argument("--concurrency", type=int, default=5)
parser.add_argument("--release-commit", required=True)
parser.add_argument("--release-record-sha256", required=True)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--confirm-sandbox", action="store_true")
args = parser.parse_args()
parsed = urllib.parse.urlparse(args.origin)
require(args.confirm_sandbox, "Use --confirm-sandbox after confirming the target and maintenance window.")
require(parsed.scheme == "https" and parsed.hostname and parsed.path in ("", "/") and not parsed.query and not parsed.fragment and not parsed.username,
"Origin must be an HTTPS origin without credentials, path, query or fragment.")
require(parsed.hostname != "localhost" and not parsed.hostname.startswith("127."), "Use the deployed HTTPS sandbox, not a development server.")
require(1 <= args.concurrency <= 20 and 1 <= args.requests <= 2000, "Probe bounds are 1–20 concurrent workers and 1–2000 requests.")
require(re.fullmatch(r"[0-9a-f]{40}", args.release_commit) is not None, "Use a full lowercase release commit SHA.")
require(re.fullmatch(r"[0-9a-f]{64}", args.release_record_sha256) is not None, "Use the release-record SHA-256.")
require(not args.output.exists() and args.output.parent.is_dir(), "Output must be a new file in an existing restricted directory.")
email = os.environ.get("CAPACITY_EMAIL", "")
password = os.environ.get("CAPACITY_PASSWORD", "")
require(email and password, "Set CAPACITY_EMAIL and CAPACITY_PASSWORD for a dedicated sandbox staff account.")
cookie = login(args.origin.rstrip("/"), email, password)
with concurrent.futures.ThreadPoolExecutor(max_workers=args.concurrency) as pool:
results = list(pool.map(lambda number: request_once(args.origin.rstrip("/"), cookie, number), range(args.requests)))
report = {
"schemaVersion": 1,
"kind": "guestops-read-only-capacity",
"recordedAt": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
"originHost": parsed.hostname,
"releaseCommit": args.release_commit,
"releaseRecordSha256": args.release_record_sha256,
"paths": list(PATHS),
**summarize(results, args.concurrency),
}
descriptor = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
output.write(json.dumps(report, indent=2, sort_keys=True) + "\n")
print(f"Capacity probe completed: {report['successes']}/{report['requests']} successful; p95 {report['latencyMs']['p95']} ms. Review against the approved target before release.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Capacity probe stopped: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

View File

@ -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": []}
]
}

View File

@ -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)

View File

@ -0,0 +1,26 @@
{
"acceptedAt": "2026-01-01T00:00:00Z",
"acceptedBy": "REPLACE WITH APPROVER",
"endedAt": "2026-01-01T00:00:00Z",
"environment": "https://sandbox-guestops.futuresens.co.uk",
"mailboxLabel": "sandbox mailbox A",
"operator": "REPLACE WITH OPERATOR",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"scenarios": [
{"id": "oauth-readonly", "status": "not-run", "evidence": []},
{"id": "initial-import", "status": "not-run", "evidence": []},
{"id": "duplicate-import", "status": "not-run", "evidence": []},
{"id": "same-account-reconnect", "status": "not-run", "evidence": []},
{"id": "different-account-rejected", "status": "not-run", "evidence": []},
{"id": "provider-revocation", "status": "not-run", "evidence": []},
{"id": "reviewed-send", "status": "not-run", "evidence": []},
{"id": "gmail-threading", "status": "not-run", "evidence": []},
{"id": "duplicate-approval", "status": "not-run", "evidence": []},
{"id": "uncertain-send-reconciliation", "status": "not-run", "evidence": []},
{"id": "sending-stop-control", "status": "not-run", "evidence": []}
],
"schemaVersion": 1,
"startedAt": "2026-01-01T00:00:00Z",
"system": "google-mailbox"
}

View File

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Validate a restricted Google mailbox acceptance record without reading its evidence."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import re
from pathlib import Path
from urllib.parse import urlparse
SCENARIOS = {
"oauth-readonly",
"initial-import",
"duplicate-import",
"same-account-reconnect",
"different-account-rejected",
"provider-revocation",
"reviewed-send",
"gmail-threading",
"duplicate-approval",
"uncertain-send-reconciliation",
"sending-stop-control",
}
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def utc_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 validate(report: object) -> None:
require(isinstance(report, dict), "Acceptance record must be a JSON object.")
require(report.get("schemaVersion") == 1, "Unsupported acceptance record schema.")
require(report.get("system") == "google-mailbox", "Acceptance record system must be google-mailbox.")
require(re.fullmatch(r"[0-9a-f]{40}", str(report.get("releaseCommit", ""))) is not None,
"releaseCommit must be a full lowercase Git SHA.")
require(re.fullmatch(r"[0-9a-f]{64}", str(report.get("releaseRecordSha256", ""))) is not None,
"releaseRecordSha256 must be a SHA-256 digest.")
environment = str(report.get("environment", ""))
parsed_url = urlparse(environment)
require(parsed_url.scheme == "https" and parsed_url.hostname and parsed_url.path in ("", "/") and not parsed_url.query and not parsed_url.fragment,
"environment must be an HTTPS origin without credentials, path, query or fragment.")
require(parsed_url.username is None and parsed_url.password is None, "environment must not contain credentials.")
mailbox_label = str(report.get("mailboxLabel", ""))
require(3 <= len(mailbox_label) <= 80 and "@" not in mailbox_label,
"mailboxLabel must be a short non-email alias; do not put mailbox addresses in the record.")
require(2 <= len(str(report.get("operator", ""))) <= 120, "operator is required.")
started = utc_timestamp(report.get("startedAt"), "startedAt")
ended = utc_timestamp(report.get("endedAt"), "endedAt")
accepted = utc_timestamp(report.get("acceptedAt"), "acceptedAt")
require(started <= ended <= accepted, "Acceptance timestamps are out of order.")
require(2 <= len(str(report.get("acceptedBy", ""))) <= 120, "acceptedBy is required.")
scenarios = report.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)), "Scenario IDs must be unique objects.")
require(set(ids) == SCENARIOS, "Acceptance record does not contain the exact required 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,
f"Scenario {scenario_id} requires one to ten restricted evidence references.")
require(all(isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in evidence),
f"Scenario {scenario_id} has an invalid evidence reference; do not include email addresses or raw evidence.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("record", type=Path)
args = parser.parse_args()
report = json.loads(args.record.read_text(encoding="utf-8"))
validate(report)
print(f"Google acceptance record is structurally complete: {len(SCENARIOS)} scenarios passed. This validates the record, not the underlying provider evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Google acceptance record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

View File

@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"system": "guestops-identity-privacy",
"targetGate": "B",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.example.invalid",
"operator": "REPLACE OPERATOR",
"reviewedBy": "REPLACE REVIEWER",
"startedAt": "2026-10-01T09:00:00Z",
"endedAt": "2026-10-01T10:00:00Z",
"reviewedAt": "2026-10-01T11:00:00Z",
"retention": {
"conversationDays": 0,
"auditDays": 0,
"backupDays": 0,
"accountDays": 0,
"privacyOwner": "REPLACE",
"deletionOwner": "REPLACE",
"legalHoldOwner": "REPLACE",
"deletionProcedure": "REPLACE-RESTRICTED-REFERENCE",
"legalHoldProcedure": "REPLACE-RESTRICTED-REFERENCE"
},
"providers": {
"google": {"status": "pending", "evidence": []},
"openai": {"status": "pending", "evidence": []}
},
"preferencesReviewed": [],
"scenarios": [
{"id": "account-lifecycle", "status": "not-run", "evidence": []},
{"id": "audit-review", "status": "not-run", "evidence": []},
{"id": "backup-retention", "status": "not-run", "evidence": []},
{"id": "data-inventory", "status": "not-run", "evidence": []},
{"id": "known-identity-limitations", "status": "not-run", "evidence": []},
{"id": "login-throttling", "status": "not-run", "evidence": []},
{"id": "preference-coverage", "status": "not-run", "evidence": []},
{"id": "provider-processing", "status": "not-run", "evidence": []},
{"id": "retention-deletion", "status": "not-run", "evidence": []},
{"id": "role-boundaries", "status": "not-run", "evidence": []},
{"id": "session-invalidation", "status": "not-run", "evidence": []}
]
}

View File

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Validate restricted Gate B identity, preference and privacy acceptance evidence."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
SCENARIOS = {
"role-boundaries", "account-lifecycle", "login-throttling", "session-invalidation",
"preference-coverage", "data-inventory", "retention-deletion", "backup-retention",
"provider-processing", "audit-review", "known-identity-limitations",
}
PREFERENCES = {
"hotel-name", "timezone", "signature", "ai-drafts", "staff-sending",
"faq-mode", "pms-updates", "payment-creation",
}
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:
return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
def name(value: object, field: str) -> str:
result = str(value or "").strip()
require(2 <= len(result) <= 120 and "@" not in result, f"{field} requires a name without an email address.")
return result
def refs(value: object, field: str) -> None:
require(isinstance(value, list) and 1 <= len(value) <= 10 and all(
isinstance(item, str) and 3 <= len(item) <= 200 and "@" not in item for item in value
), f"{field} requires safe opaque evidence references.")
def validate(record: object) -> None:
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported identity/privacy acceptance schema.")
require(record.get("system") == "guestops-identity-privacy", "system must be guestops-identity-privacy.")
require(record.get("targetGate") == "B", "Identity/privacy acceptance must target Gate B.")
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.")
operator = name(record.get("operator"), "operator")
reviewer = 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.")
retention = record.get("retention")
require(isinstance(retention, dict), "retention decisions are required.")
for field in ("conversationDays", "auditDays", "backupDays", "accountDays"):
value = retention.get(field)
require(isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 3650,
f"retention.{field} must be between 1 and 3650 days.")
for field in ("privacyOwner", "deletionOwner", "legalHoldOwner"):
name(retention.get(field), f"retention.{field}")
for field in ("deletionProcedure", "legalHoldProcedure"):
refs([retention.get(field)], f"retention.{field}")
providers = record.get("providers")
require(isinstance(providers, dict) and set(providers) == {"google", "openai"},
"providers must contain exactly google and openai decisions.")
require(all(isinstance(decision, dict) for decision in providers.values()),
"Each provider decision must be an object.")
require(providers["google"].get("status") == "accepted", "Google processing must be accepted for the Gate B mailbox pilot.")
require(providers["openai"].get("status") in ("accepted", "disabled"), "OpenAI processing must be accepted or disabled.")
for provider, decision in providers.items():
refs(decision.get("evidence"), f"Provider {provider}")
preferences = record.get("preferencesReviewed")
require(isinstance(preferences, list) and all(isinstance(item, str) for item in preferences)
and set(preferences) == PREFERENCES and len(preferences) == len(PREFERENCES),
"preferencesReviewed must contain the exact owner-controlled preference set.")
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 identity/privacy scenario set.")
for item in scenarios:
require(item.get("status") == "pass", f"Scenario {item['id']} has not passed.")
refs(item.get("evidence"), f"Scenario {item['id']}")
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("Identity/privacy acceptance record is structurally complete. This validates the record, not its restricted evidence or legal decisions.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Identity/privacy acceptance record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

View File

@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"system": "guestops-incident-exercise",
"targetGate": "B",
"dataClassification": "synthetic-only",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.example.invalid",
"operator": "Exercise operator",
"incidentCommander": "Incident commander",
"reviewedBy": "Independent reviewer",
"startedAt": "2026-09-29T09:00:00Z",
"endedAt": "2026-09-29T10:00:00Z",
"reviewedAt": "2026-09-29T11:00:00Z",
"targetsMinutes": {
"detection": 10,
"containment": 20,
"recovery": 60
},
"observedMinutes": {
"detection": 0,
"containment": 0,
"recovery": 0
},
"scenarios": [
{"id": "alert-and-escalate", "status": "not-run", "evidence": []},
{"id": "controlled-recovery", "status": "not-run", "evidence": []},
{"id": "disable-worker-writes", "status": "not-run", "evidence": []},
{"id": "google-uncertain-send", "status": "not-run", "evidence": []},
{"id": "image-rollback", "status": "not-run", "evidence": []},
{"id": "restore-readiness", "status": "not-run", "evidence": []}
],
"postExerciseState": {
"externalWrites": "disabled",
"faqMode": "off",
"unresolvedOperations": 0
}
}

132
deploy/incident_exercise.py Normal file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Validate a restricted GuestOps incident and rollback exercise record."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
GATE_B_SCENARIOS = {
"alert-and-escalate",
"disable-worker-writes",
"google-uncertain-send",
"restore-readiness",
"image-rollback",
"controlled-recovery",
}
GATE_C_SCENARIOS = GATE_B_SCENARIOS | {
"pms-ambiguous-write",
"payment-ambiguous-create",
}
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def utc_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), "Exercise record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported exercise record schema.")
require(record.get("system") == "guestops-incident-exercise",
"Exercise record system must be guestops-incident-exercise.")
gate = record.get("targetGate")
require(gate in ("B", "C"), "targetGate must be B or C.")
require(record.get("dataClassification") == "synthetic-only",
"Incident exercises must use synthetic data only.")
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.")
environment = str(record.get("environment", ""))
parsed_url = urlparse(environment)
require(parsed_url.scheme == "https" and parsed_url.hostname and parsed_url.path in ("", "/")
and not parsed_url.query and not parsed_url.fragment and parsed_url.username is None
and parsed_url.password is None,
"environment must be an HTTPS origin without credentials, path, query or fragment.")
operator = safe_name(record.get("operator"), "operator")
commander = safe_name(record.get("incidentCommander"), "incidentCommander")
reviewer = safe_name(record.get("reviewedBy"), "reviewedBy")
require(len({operator.casefold(), commander.casefold(), reviewer.casefold()}) == 3,
"operator, incidentCommander and reviewedBy must be different people.")
started = utc_timestamp(record.get("startedAt"), "startedAt")
ended = utc_timestamp(record.get("endedAt"), "endedAt")
reviewed = utc_timestamp(record.get("reviewedAt"), "reviewedAt")
require(started <= ended <= reviewed, "Exercise timestamps are out of order.")
targets = record.get("targetsMinutes")
observed = record.get("observedMinutes")
require(isinstance(targets, dict) and isinstance(observed, dict),
"targetsMinutes and observedMinutes are required.")
metric_keys = {"detection", "containment", "recovery"}
require(set(targets) == metric_keys and set(observed) == metric_keys,
"Timing records require exactly detection, containment and recovery.")
for metric in sorted(metric_keys):
target = targets[metric]
actual = observed[metric]
require(isinstance(target, (int, float)) and not isinstance(target, bool) and 0 < target <= 1440,
f"{metric} target must be greater than zero and no more than 1440 minutes.")
require(isinstance(actual, (int, float)) and not isinstance(actual, bool) and 0 <= actual <= target,
f"Observed {metric} time must meet its pre-agreed target.")
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)]
required = GATE_C_SCENARIOS if gate == "C" else GATE_B_SCENARIOS
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == required,
f"Gate {gate} requires the exact incident 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.")
final_state = record.get("postExerciseState")
require(isinstance(final_state, dict) and final_state == {
"externalWrites": "disabled",
"faqMode": "off",
"unresolvedOperations": 0,
}, "Exercise must end with writes disabled, FAQ mode off and no unresolved operations.")
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("Incident exercise record is structurally complete and passed. This validates the record, not its restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Incident exercise record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

View File

@ -55,6 +55,27 @@ def provider_configured(config, target):
return config not in ({}, {section: {"Hotels": {}}}) return config not in ({}, {section: {"Hotels": {}}})
def persistence_layout(config):
"""Return the resolved named volumes required to survive recreation."""
services = config["services"]
declared = config.get("volumes", {})
def volume_for(service, target):
matches = [v for v in services[service].get("volumes", []) if v["target"] == target]
require(len(matches) == 1 and matches[0]["type"] == "volume" and matches[0].get("source"),
f"{service} requires one named persistent volume at {target}.")
source = matches[0]["source"]
require(source in declared, f"{service} volume {source} must be declared at the top level.")
return declared[source].get("name") or source
api_keys = volume_for("api", "/var/lib/guestops/keys")
worker_keys = volume_for("worker", "/var/lib/guestops/keys")
require(api_keys == worker_keys, "API and worker key volumes differ.")
mongo_data = volume_for("mongo", "/data/db")
require(api_keys != mongo_data, "Database and key data require separate named volumes.")
return {"keys": api_keys, "database": mongo_data}
AUTH = 'const c=new Mongo("mongodb://127.0.0.1"); c.getDB("admin").auth(process.env.MONGO_INITDB_ROOT_USERNAME,process.env.MONGO_INITDB_ROOT_PASSWORD);' AUTH = 'const c=new Mongo("mongodb://127.0.0.1"); c.getDB("admin").auth(process.env.MONGO_INITDB_ROOT_USERNAME,process.env.MONGO_INITDB_ROOT_PASSWORD);'
INVENTORY = 'const d=c.getDB("guestops"); print(JSON.stringify({bytes:d.stats().storageSize+d.stats().indexSize,collections:Object.fromEntries(d.getCollectionNames().filter(n=>!n.startsWith("system.")).sort().map(n=>[n,{count:d.getCollection(n).countDocuments({}),indexes:d.getCollection(n).getIndexes().map(i=>{delete i.ns;return i;}).sort((a,b)=>a.name.localeCompare(b.name))}]))}));' INVENTORY = 'const d=c.getDB("guestops"); print(JSON.stringify({bytes:d.stats().storageSize+d.stats().indexSize,collections:Object.fromEntries(d.getCollectionNames().filter(n=>!n.startsWith("system.")).sort().map(n=>[n,{count:d.getCollection(n).countDocuments({}),indexes:d.getCollection(n).getIndexes().map(i=>{delete i.ns;return i;}).sort((a,b)=>a.name.localeCompare(b.name))}]))}));'
@ -78,15 +99,60 @@ def configuration():
require(env.get("Keys__Path") == "/var/lib/guestops/keys", "Unexpected key directory.") require(env.get("Keys__Path") == "/var/lib/guestops/keys", "Unexpected key directory.")
require(services["api"]["environment"].get("ASPNETCORE_ENVIRONMENT") == "Production", "API must use Production environment.") require(services["api"]["environment"].get("ASPNETCORE_ENVIRONMENT") == "Production", "API must use Production environment.")
require(re.fullmatch(r"https://[A-Za-z0-9.-]+", services["api"]["environment"].get("PublicUrl", "")), "PublicUrl must be an HTTPS hostname without a path.") require(re.fullmatch(r"https://[A-Za-z0-9.-]+", services["api"]["environment"].get("PublicUrl", "")), "PublicUrl must be an HTTPS hostname without a path.")
key_sources = [] persistence_layout(config)
for name in ("api", "worker"):
keys = [v for v in services[name].get("volumes", []) if v["target"] == "/var/lib/guestops/keys"]
require(len(keys) == 1 and keys[0]["type"] == "volume", "API and worker require a shared persistent key volume.")
key_sources.append(keys[0]["source"])
require(key_sources[0] == key_sources[1], "API and worker key volumes differ.")
return config return config
def wait_for(action, message, attempts=60):
for attempt in range(attempts):
try:
action()
return
except Exception:
if attempt == attempts - 1:
raise RuntimeError(message)
time.sleep(1)
def volume_identity(name):
require(not name.startswith("-"), "Invalid volume name.")
details = json.loads(run(["docker", "volume", "inspect", name]))
require(len(details) == 1 and details[0].get("Name") == name, "Named volume inspection failed.")
return {"name": name, "driver": details[0].get("Driver"), "scope": details[0].get("Scope")}
def readiness():
with urllib.request.urlopen("http://127.0.0.1:8080/health/ready", timeout=10) as response:
require(json.load(response) == {"status": "ready"}, "Loopback readiness failed.")
def persistence_drill(args):
require(args.confirm_restart, "Persistence drill requires --confirm-restart: all services will be restarted and application containers recreated.")
config = configuration()
layout = persistence_layout(config)
before_volumes = {purpose: volume_identity(name) for purpose, name in layout.items()}
before_inventory = json.loads(mongo(INVENTORY))
probe = compose("run", "--rm", "--no-deps", "-T", "-e", "Logging__LogLevel__Default=None", "api", "--backup-probe").decode().strip()
require(re.fullmatch(r"[A-Za-z0-9_-]{20,4096}", probe), "Key probe did not return a valid protected value.")
# Restart the database first, then its clients, so recovery is exercised in a known order.
compose("restart", "-t", "150", "mongo")
wait_for(lambda: mongo('const r=c.getDB("admin").runCommand({ping:1});if(!r.ok)quit(1);'), "MongoDB did not recover after restart.")
compose("restart", "-t", "150", "api", "worker")
wait_for(readiness, "API readiness did not recover after restart.")
# Recreate stateless containers as an image upgrade would, without rebuilding or changing data volumes.
compose("up", "-d", "--no-build", "--force-recreate", "api", "worker")
wait_for(readiness, "API readiness did not recover after container recreation.")
compose("run", "--rm", "--no-deps", "-T", "-e", "Logging__LogLevel__Default=None", "-e", "BACKUP_PROBE=" + probe, "api", "--verify-backup-probe")
after_volumes = {purpose: volume_identity(name) for purpose, name in layout.items()}
after_inventory = json.loads(mongo(INVENTORY))
require(after_volumes == before_volumes, "A persistent volume identity changed during the drill.")
require(after_inventory["collections"] == before_inventory["collections"], "Database collection counts or indexes changed during the drill.")
print("Persistence drill passed: named volumes, database counts/indexes, data-protection keys and readiness survived restart and container recreation.")
def preflight(args): def preflight(args):
config = configuration() config = configuration()
env_file = ROOT / ".env" env_file = ROOT / ".env"
@ -178,6 +244,24 @@ def backup(args):
print("Encrypted backup complete. Copy it off-server and perform a restore drill.") print("Encrypted backup complete. Copy it off-server and perform a restore drill.")
def scheduled_backup(args):
require(args.confirm_maintenance, "Scheduled backup requires --confirm-maintenance because API and workers will briefly stop.")
recipient = os.environ.get("BACKUP_RECIPIENT", "")
directory_value = os.environ.get("BACKUP_DIRECTORY", "")
require(re.fullmatch(r"[A-Fa-f0-9]{40}", recipient), "BACKUP_RECIPIENT must be a verified full GPG fingerprint.")
require(directory_value != "", "BACKUP_DIRECTORY must name the private off-checkout staging directory.")
requested_directory = Path(directory_value)
require(requested_directory.is_absolute() and not requested_directory.is_symlink(), "BACKUP_DIRECTORY must be an absolute, non-symlink path.")
directory = requested_directory.resolve()
require(directory.is_dir(), "BACKUP_DIRECTORY must be an existing real directory.")
require(ROOT not in directory.parents and directory != ROOT, "Scheduled backups must be staged outside the application checkout.")
require(stat.S_IMODE(directory.stat().st_mode) & 0o077 == 0, "BACKUP_DIRECTORY must be private (mode 700).")
timestamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
destination = directory / f"guestops-{timestamp}.tar.gpg"
backup(argparse.Namespace(confirm_maintenance=True, recipient=recipient, output=str(destination)))
print(f"Scheduled backup staged as {destination.name}. Off-host transfer and alert verification remain required.")
def unpack(bundle, folder): def unpack(bundle, folder):
with tarfile.open(bundle, "r:") as archive: with tarfile.open(bundle, "r:") as archive:
members = archive.getmembers() members = archive.getmembers()
@ -242,10 +326,12 @@ def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
subs = parser.add_subparsers(dest="command", required=True) subs = parser.add_subparsers(dest="command", required=True)
check = subs.add_parser("preflight"); check.add_argument("--offline", action="store_true") check = subs.add_parser("preflight"); check.add_argument("--offline", action="store_true")
persistence = subs.add_parser("persistence-drill"); persistence.add_argument("--confirm-restart", action="store_true")
save = subs.add_parser("backup"); save.add_argument("--recipient", required=True); save.add_argument("--output", required=True); save.add_argument("--confirm-maintenance", action="store_true") save = subs.add_parser("backup"); save.add_argument("--recipient", required=True); save.add_argument("--output", required=True); save.add_argument("--confirm-maintenance", action="store_true")
scheduled = subs.add_parser("scheduled-backup"); scheduled.add_argument("--confirm-maintenance", action="store_true")
drill = subs.add_parser("restore-drill"); drill.add_argument("backup"); drill.add_argument("--api-image", required=True); drill.add_argument("--mongo-image", default="mongo:8.0") drill = subs.add_parser("restore-drill"); drill.add_argument("backup"); drill.add_argument("--api-image", required=True); drill.add_argument("--mongo-image", default="mongo:8.0")
args = parser.parse_args() args = parser.parse_args()
{"preflight": preflight, "backup": backup, "restore-drill": restore_drill}[args.command](args) {"preflight": preflight, "persistence-drill": persistence_drill, "backup": backup, "scheduled-backup": scheduled_backup, "restore-drill": restore_drill}[args.command](args)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -0,0 +1,34 @@
{
"approvals": {
"hotelOwner": {"approvedAt": "2026-01-01T00:00:00Z", "name": "REPLACE"},
"technicalOwner": {"approvedAt": "2026-01-01T00:00:00Z", "name": "REPLACE"}
},
"capacity": {
"hostMetricsSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"observedConcurrency": 0,
"observedCpuHeadroomPercent": 0,
"observedErrorRate": 1,
"observedMemoryHeadroomPercent": 0,
"observedP95Ms": 0,
"reportSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"targetConcurrency": 1,
"targetCpuHeadroomPercent": 25,
"targetErrorRate": 0,
"targetMemoryHeadroomPercent": 25,
"targetP95Ms": 0
},
"decidedAt": "2026-01-01T00:00:00Z",
"decision": "pending",
"evidence": [],
"pilot": {
"businessDaysObserved": 0,
"hotelsObserved": 0,
"recordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"stopConditionsObserved": 1,
"unresolvedFindings": 1
},
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"schemaVersion": 2,
"targetGate": "B"
}

View File

@ -0,0 +1,47 @@
{
"schemaVersion": 1,
"system": "guestops-supervised-pilot",
"targetGate": "B",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.example.invalid",
"hotelLabel": "approved-pilot-hotel",
"hotelCount": 1,
"plannedBusinessDays": 5,
"owners": {
"hotelOwner": "REPLACE HOTEL OWNER",
"technicalOwner": "REPLACE TECHNICAL OWNER",
"rollbackDecisionMaker": "REPLACE ROLLBACK OWNER"
},
"startedOn": "2026-10-05",
"endedOn": "2026-10-09",
"pilotControls": {
"pmsWrites": "disabled",
"paymentCreation": "disabled",
"faqMode": "off",
"googleReviewedSending": "accepted"
},
"dailyReviews": [
{"date": "2026-10-05", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []},
{"date": "2026-10-06", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []},
{"date": "2026-10-07", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []},
{"date": "2026-10-08", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []},
{"date": "2026-10-09", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []}
],
"stopConditions": {
"tenant-leakage": false,
"credential-exposure": false,
"data-loss": false,
"unapproved-send": false,
"duplicate-send": false,
"unreconciled-uncertain-send": false,
"failed-rollback": false,
"monitoring-loss": false
},
"findings": [],
"postPilotState": {
"pmsWrites": "disabled",
"paymentCreation": "disabled",
"faqMode": "off"
}
}

129
deploy/pilot_approval.py Normal file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Validate a GuestOps supervised-pilot go/no-go record."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
GATE_B = {
"release-ci", "debian-host", "persistence", "backup-restore", "google-mailbox",
"automation", "identity-privacy", "inbox-usability", "capacity",
"incident-support", "pilot-findings",
}
GATE_C = GATE_B | {"pms-provider", "payment-provider"}
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[:-1] + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
return parsed
def validate(record: object) -> None:
require(isinstance(record, dict), "Approval record must be a JSON object.")
require(record.get("schemaVersion") == 2, "Unsupported approval schema; Gate B 0.2.0 requires schemaVersion 2.")
gate = record.get("targetGate")
require(gate in ("B", "C"), "targetGate must be B or C.")
require(record.get("decision") == "approved", "Only an explicit approved decision passes validation.")
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.")
decided = timestamp(record.get("decidedAt"), "decidedAt")
approvals = record.get("approvals")
require(isinstance(approvals, dict) and set(approvals) == {"hotelOwner", "technicalOwner"},
"Separate hotelOwner and technicalOwner approvals are required.")
approver_names = []
for role, approval in approvals.items():
name = str(approval.get("name", "")).strip() if isinstance(approval, dict) else ""
require(2 <= len(name) <= 120 and "@" not in name,
f"{role} approval requires a named owner without an email address.")
approver_names.append(name.casefold())
require(timestamp(approval.get("approvedAt"), f"{role}.approvedAt") <= decided,
f"{role} approval cannot occur after the decision.")
require(len(set(approver_names)) == 2, "hotelOwner and technicalOwner must be different people.")
evidence = record.get("evidence")
require(isinstance(evidence, list), "evidence must be a list.")
ids = [item.get("id") for item in evidence if isinstance(item, dict)]
required = GATE_C if gate == "C" else GATE_B
require(len(ids) == len(evidence) and len(ids) == len(set(ids)) and set(ids) == required,
f"Gate {gate} requires the exact evidence set.")
for item in evidence:
item_id = item["id"]
status = item.get("status")
require(status in ("pass", "contained"), f"Evidence {item_id} must pass or have approved containment.")
references = item.get("references")
require(isinstance(references, list) and 1 <= len(references) <= 10 and all(isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in references),
f"Evidence {item_id} requires safe opaque references without email addresses.")
if status == "contained":
containment = item.get("containment")
require(isinstance(containment, dict), f"Evidence {item_id} requires containment details.")
require(2 <= len(str(containment.get("owner", ""))) <= 120, f"Evidence {item_id} containment requires an owner.")
require(timestamp(containment.get("expiresAt"), f"{item_id}.containment.expiresAt") > decided,
f"Evidence {item_id} containment must expire after the decision.")
require(5 <= len(str(containment.get("rollbackTrigger", ""))) <= 300,
f"Evidence {item_id} containment requires a rollback trigger.")
capacity = record.get("capacity")
require(isinstance(capacity, dict), "Capacity thresholds and observations are required.")
require(re.fullmatch(r"[0-9a-f]{64}", str(capacity.get("reportSha256", ""))) is not None,
"capacity.reportSha256 must identify the retained probe report.")
require(re.fullmatch(r"[0-9a-f]{64}", str(capacity.get("hostMetricsSha256", ""))) is not None,
"capacity.hostMetricsSha256 must identify the retained host metrics.")
for observed, target in (("observedP95Ms", "targetP95Ms"), ("observedErrorRate", "targetErrorRate")):
values = (capacity.get(observed), capacity.get(target))
require(all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in values)
and 0 <= capacity[observed] <= capacity[target],
f"Capacity {observed} must be within its approved {target}.")
require(capacity["targetP95Ms"] > 0, "Capacity targetP95Ms must be positive.")
require(capacity["targetErrorRate"] <= 1, "Capacity targetErrorRate must be a ratio no greater than 1.")
concurrency = (capacity.get("observedConcurrency"), capacity.get("targetConcurrency"))
require(all(isinstance(value, int) and not isinstance(value, bool) for value in concurrency)
and capacity["observedConcurrency"] >= capacity["targetConcurrency"] > 0,
"Observed concurrency must meet the approved positive target.")
for resource in ("Cpu", "Memory"):
target = capacity.get(f"target{resource}HeadroomPercent")
observed = capacity.get(f"observed{resource}HeadroomPercent")
require(all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in (target, observed))
and 25 <= target <= observed <= 100,
f"Observed {resource.lower()} headroom must meet the approved target of at least 25 percent.")
pilot = record.get("pilot")
require(isinstance(pilot, dict), "A supervised pilot summary is required.")
require(re.fullmatch(r"[0-9a-f]{64}", str(pilot.get("recordSha256", ""))) is not None,
"pilot.recordSha256 must identify the retained pilot run record.")
require(pilot.get("businessDaysObserved") == 5, "The pilot must cover exactly five business days.")
require(pilot.get("hotelsObserved") == 1, "The Gate B pilot must cover exactly one hotel.")
require(pilot.get("stopConditionsObserved") == 0, "A pilot with a stop condition cannot be approved.")
require(pilot.get("unresolvedFindings") == 0, "All pilot findings must be resolved or explicitly contained.")
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("Pilot approval record is structurally complete and approved. This validates the record, not its restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Pilot approval rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

163
deploy/pilot_run.py Normal file
View File

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Validate a restricted five-business-day GuestOps pilot run record."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
STOP_CONDITIONS = {
"tenant-leakage",
"credential-exposure",
"data-loss",
"unapproved-send",
"duplicate-send",
"unreconciled-uncertain-send",
"failed-rollback",
"monitoring-loss",
}
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def date_value(value: object, field: str) -> dt.date:
require(isinstance(value, str), f"{field} must be an ISO date.")
try:
return dt.date.fromisoformat(value)
except ValueError as error:
raise ValueError(f"{field} must be an ISO date.") from error
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:
return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
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 references(value: object, field: str) -> None:
require(isinstance(value, list) and 1 <= len(value) <= 10 and all(
isinstance(item, str) and 3 <= len(item) <= 200 and "@" not in item for item in value
), f"{field} requires one to ten safe opaque references without email addresses.")
def business_dates(start: dt.date, end: dt.date) -> list[dt.date]:
days = []
current = start
while current <= end:
if current.weekday() < 5:
days.append(current)
current += dt.timedelta(days=1)
return days
def validate(record: object) -> None:
require(isinstance(record, dict), "Pilot run record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported pilot run schema.")
require(record.get("system") == "guestops-supervised-pilot", "Pilot run system must be guestops-supervised-pilot.")
require(record.get("targetGate") == "B", "This pilot run record is restricted to Gate B.")
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.")
label = str(record.get("hotelLabel", "")).strip()
require(3 <= len(label) <= 80 and "@" not in label, "hotelLabel must be a non-email alias.")
require(record.get("hotelCount") == 1, "Gate B pilot must contain exactly one hotel.")
require(record.get("plannedBusinessDays") == 5, "Gate B pilot must require five business days.")
owners = record.get("owners")
require(isinstance(owners, dict) and set(owners) == {"hotelOwner", "technicalOwner", "rollbackDecisionMaker"},
"owners must contain hotelOwner, technicalOwner and rollbackDecisionMaker.")
names = {role: safe_name(value, f"owners.{role}") for role, value in owners.items()}
require(names["hotelOwner"].casefold() != names["technicalOwner"].casefold(),
"hotelOwner and technicalOwner must be different people.")
start = date_value(record.get("startedOn"), "startedOn")
end = date_value(record.get("endedOn"), "endedOn")
expected_days = business_dates(start, end)
require(len(expected_days) == 5, "Pilot window must contain exactly five business days.")
reviews = record.get("dailyReviews")
require(isinstance(reviews, list) and len(reviews) == 5, "Exactly five daily reviews are required.")
review_dates = []
for index, review in enumerate(reviews):
require(isinstance(review, dict), f"dailyReviews[{index}] must be an object.")
review_date = date_value(review.get("date"), f"dailyReviews[{index}].date")
review_dates.append(review_date)
require(review.get("status") == "pass", f"Daily review {review_date} has not passed.")
safe_name(review.get("reviewedBy"), f"dailyReviews[{index}].reviewedBy")
references(review.get("evidence"), f"dailyReviews[{index}].evidence")
require(review_dates == expected_days, "Daily reviews must cover each business day in chronological order.")
controls = record.get("pilotControls")
require(controls == {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off",
"googleReviewedSending": "accepted"},
"Pilot controls require accepted reviewed Google sending with PMS, payments and FAQ live mode disabled.")
stop_conditions = record.get("stopConditions")
require(isinstance(stop_conditions, dict) and set(stop_conditions) == STOP_CONDITIONS,
"stopConditions must contain the exact Gate B stop-condition set.")
require(all(value is False for value in stop_conditions.values()),
"A pilot with an observed stop condition cannot pass.")
findings = record.get("findings")
require(isinstance(findings, list), "findings must be a list, including an empty list when none were found.")
finding_ids = []
for finding in findings:
require(isinstance(finding, dict), "Each finding must be an object.")
finding_id = str(finding.get("id", ""))
require(re.fullmatch(r"[a-z0-9][a-z0-9-]{2,79}", finding_id) is not None, "Finding IDs must be safe opaque identifiers.")
finding_ids.append(finding_id)
severity = finding.get("severity")
disposition = finding.get("disposition")
require(severity in ("critical", "high", "medium", "low"), f"Finding {finding_id} has an invalid severity.")
require(disposition in ("resolved", "contained"), f"Finding {finding_id} must be resolved or contained.")
references(finding.get("evidence"), f"Finding {finding_id} evidence")
require(not (severity in ("critical", "high") and disposition == "contained"),
f"Finding {finding_id} is too severe for containment.")
if disposition == "contained":
containment = finding.get("containment")
require(isinstance(containment, dict), f"Finding {finding_id} requires containment details.")
safe_name(containment.get("owner"), f"Finding {finding_id} containment owner")
require(timestamp(containment.get("expiresAt"), f"Finding {finding_id}.containment.expiresAt").date() > end,
f"Finding {finding_id} containment must expire after the pilot.")
require(5 <= len(str(containment.get("rollbackTrigger", ""))) <= 300,
f"Finding {finding_id} containment requires a rollback trigger.")
require(len(finding_ids) == len(set(finding_ids)), "Finding IDs must be unique.")
require(record.get("postPilotState") == {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off"},
"Pilot must end with PMS writes, payment creation and FAQ live mode disabled.")
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("Supervised pilot record is structurally complete: five business days passed without a stop condition. This validates the record, not its restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Supervised pilot record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

76
deploy/release_record.py Normal file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Create deterministic evidence for a reviewed GuestOps image archive."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def project_versions() -> tuple[str, str]:
props = (ROOT / "Directory.Build.props").read_text(encoding="utf-8")
match = re.search(r"<Version>([^<]+)</Version>", props)
if match is None:
raise ValueError("Directory.Build.props does not contain a Version element")
package = json.loads((ROOT / "web" / "package.json").read_text(encoding="utf-8"))
return match.group(1), str(package["version"])
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--artifact", required=True, type=Path)
parser.add_argument("--commit", required=True)
parser.add_argument("--api-image", required=True)
parser.add_argument("--api-id", required=True)
parser.add_argument("--worker-image", required=True)
parser.add_argument("--worker-id", required=True)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
commit = args.commit.lower()
if re.fullmatch(r"[0-9a-f]{40}", commit) is None:
parser.error("--commit must be a full 40-character Git SHA")
if not args.artifact.is_file():
parser.error("--artifact must name an existing file")
dotnet_version, web_version = project_versions()
if dotnet_version != web_version:
parser.error(
f"release versions differ: .NET={dotnet_version}, web={web_version}"
)
record = {
"artifact": {
"name": args.artifact.name,
"sha256": sha256(args.artifact),
"size": args.artifact.stat().st_size,
},
"commit": commit,
"images": {
"api": {"id": args.api_id, "reference": args.api_image},
"worker": {"id": args.worker_id, "reference": args.worker_image},
},
"schemaVersion": 1,
"version": dotnet_version,
}
args.output.write_text(
json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,21 @@
[Unit]
Description=GuestOps encrypted maintenance backup
Requires=docker.service
After=docker.service
ConditionPathIsDirectory=/srv/guestops
ConditionPathIsDirectory=/var/backups/guestops
ConditionPathIsDirectory=/var/lib/guestops-backup/gnupg
[Service]
Type=oneshot
WorkingDirectory=/srv/guestops
EnvironmentFile=/etc/guestops/backup.env
Environment=GNUPGHOME=/var/lib/guestops-backup/gnupg
UMask=0077
ExecStart=/usr/bin/python3 /srv/guestops/deploy/ops.py scheduled-backup --confirm-maintenance
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths=/var/backups/guestops /var/lib/guestops-backup/gnupg
TimeoutStartSec=45min

View File

@ -0,0 +1,11 @@
[Unit]
Description=Run the GuestOps encrypted backup each day
[Timer]
OnCalendar=*-*-* 02:17:00 UTC
RandomizedDelaySec=30min
Persistent=true
Unit=guestops-backup.service
[Install]
WantedBy=timers.target

View File

@ -14,6 +14,10 @@ The first version requires a top-level plain-text MIME message. HTML and multipa
Keep `AUTO_REPLY_ENABLE_LIVE=false` in the deployment `.env` until the Google send/reconciliation workflow and FAQ test-mode results have been accepted. Then set it true and restart both API and worker. Gmail sending must be configured, the hotel must enable staff sending, and the mailbox must have send consent. Finally, the owner explicitly confirms test-mode acceptance and selects **Enable live replies**. Keep `AUTO_REPLY_ENABLE_LIVE=false` in the deployment `.env` until the Google send/reconciliation workflow and FAQ test-mode results have been accepted. Then set it true and restart both API and worker. Gmail sending must be configured, the hotel must enable staff sending, and the mailbox must have send consent. Finally, the owner explicitly confirms test-mode acceptance and selects **Enable live replies**.
Before test-mode acceptance, use **Batch safety evaluation** with representative synthetic cases. Each JSON case has a unique `id`, `subject`, `body`, `expectedMatch`, and optionally `expectedKnowledgeId`. The no-send evaluator accepts 1–100 cases, reads the hotel's current reviewed rules and knowledge, and reports false positives, false negatives and per-case reasons without saving a conversation or consuming a quota.
The minimum activation gate is zero false positives, zero false negatives for every supported exact question, and explicit exclusions covering additional requests, booking/payment/refund language, emergencies, accessibility or medical context, greetings/signatures, reply threads, and unsupported wording. Where `expectedMatch` is true, set `expectedKnowledgeId` so the case also proves the intended approved answer was selected. Keep the evaluated case set and result with the restricted acceptance record. Passing content cases does not replace mailbox-header, threading, quota, stop-control or delivery acceptance.
All modes default to Off. Every mode change creates a new activation boundary and invalidates previous queued automatic approvals. Only untouched messages received after activation and within the last 24 hours are eligible. Switching Test to Live does not send replies to previous test matches. The evaluation worker runs about every 30 seconds; the existing delivery worker handles approved outgoing messages. All modes default to Off. Every mode change creates a new activation boundary and invalidates previous queued automatic approvals. Only untouched messages received after activation and within the last 24 hours are eligible. Switching Test to Live does not send replies to previous test matches. The evaluation worker runs about every 30 seconds; the existing delivery worker handles approved outgoing messages.
## Limits and rechecks ## Limits and rechecks
@ -36,4 +40,4 @@ The database retains thread/recipient/quota reservations and evaluation evidence
Automated tests use fake provider handlers and MongoDB; they never send live emails. They cover exact matching, exclusions, tenant boundaries, changed answers, concurrent evaluation, quotas, rule/epoch invalidation, Gmail-thread changes and uncertain delivery. Live acceptance remains pending. Automated tests use fake provider handlers and MongoDB; they never send live emails. They cover exact matching, exclusions, tenant boundaries, changed answers, concurrent evaluation, quotas, rule/epoch invalidation, Gmail-thread changes and uncertain delivery. Live acceptance remains pending.
Test allowed questions and exclusions with your sandbox mailbox, verify duplicate prevention across restarts, inspect the actual received email, and exercise the stop control before enabling a hotel. Broad natural-language matching, multilingual questions, greetings/signature stripping, HTML/multipart equivalence and higher throughput are follow-on work requiring representative evaluation. PMS actions, payment requests and complex guest issues remain staff workflows. Test allowed questions and exclusions with your sandbox mailbox, verify duplicate prevention across restarts, inspect the actual received email, and exercise the stop control before enabling a hotel. Record the owner responsible for rule changes, the operator monitoring the first live window, the rollback decision-maker, and the duration of heightened monitoring. Any false positive, unexpected recipient, duplicate, uncertain unreviewed outcome, or changed knowledge answer is a stop condition: select **Turn off**, preserve evidence, and reconcile in-flight work before considering reactivation. Broad natural-language matching, multilingual questions, greetings/signature stripping, HTML/multipart equivalence and higher throughput are follow-on work requiring representative evaluation. PMS actions, payment requests and complex guest issues remain staff workflows.

View File

@ -54,7 +54,7 @@ Merge `deploy/nginx.conf` into the existing host configuration, check with `ngin
Compose reserves the private bridge `172.30.87.0/24`, with gateway `172.30.87.1`. The API trusts the host gateway's `X-Forwarded-Proto` header so Nginx's HTTPS connections receive secure session and CSRF cookies. Check for an existing network using that range. If it conflicts, set both `GUESTOPS_SUBNET` and `GUESTOPS_GATEWAY` in `.env` to a free matching subnet and gateway. Do not replace this with unrestricted forwarded-header trust. Compose reserves the private bridge `172.30.87.0/24`, with gateway `172.30.87.1`. The API trusts the host gateway's `X-Forwarded-Proto` header so Nginx's HTTPS connections receive secure session and CSRF cookies. Check for an existing network using that range. If it conflicts, set both `GUESTOPS_SUBNET` and `GUESTOPS_GATEWAY` in `.env` to a free matching subnet and gateway. Do not replace this with unrestricted forwarded-header trust.
The initial rate limiter keys off the direct peer address. Behind this loopback reverse proxy it is shared across users (10 login attempts/minute), which is conservative for a pilot. Before scaling, configure explicitly trusted forwarded headers and per-account/IP rate limits; never trust arbitrary client-supplied forwarding headers. The login rate limiter uses the client address forwarded by the explicitly configured host proxy (10 attempts per client address per minute). ASP.NET accepts one forwarding hop only from `Proxy__KnownAddress`; arbitrary client-supplied forwarding headers are not trusted. Keep the API port loopback-only and update the known address together with any reviewed Compose subnet change.
## 5. Configure Google ## 5. Configure Google
@ -73,3 +73,17 @@ A multi-hotel production launch using Gmail restricted scopes requires planning
Verify separate hotels cannot read or edit each other's records; save and reload settings; restart services and confirm persistence; import test messages twice without duplicates; check the worker resumes a paginated import; confirm no mail is sent without explicit staff approval and that default-disabled sending remains blocked. Review the activity log and Google account used by the connection. Verify separate hotels cannot read or edit each other's records; save and reload settings; restart services and confirm persistence; import test messages twice without duplicates; check the worker resumes a paginated import; confirm no mail is sent without explicit staff approval and that default-disabled sending remains blocked. Review the activity log and Google account used by the connection.
Keep reviewed image IDs and release images for rollback and backups of both MongoDB and the key volume. Do not remove named volumes to fix application errors. The initial release has no automatic schema migration that destroys data. Use the [operational preflight, encrypted backup and isolated restore drill](operations.md), and establish retention and off-server copies before importing real guest data. `/health/ready` checks database reachability; the owner's Workspace health page also reports worker heartbeat and mailbox/reply exceptions. Keep reviewed image IDs and release images for rollback and backups of both MongoDB and the key volume. Do not remove named volumes to fix application errors. The initial release has no automatic schema migration that destroys data. Use the [operational preflight, encrypted backup and isolated restore drill](operations.md), and establish retention and off-server copies before importing real guest data. `/health/ready` checks database reachability; the owner's Workspace health page also reports worker heartbeat and mailbox/reply exceptions.
Before accepting the host, run the confirmation-gated persistence drill during an announced maintenance window:
```sh
python3 deploy/ops.py preflight
python3 deploy/ops.py persistence-drill --confirm-restart
python3 deploy/ops.py preflight
```
The drill restarts MongoDB, API and worker, then force-recreates the stateless application containers using the already selected images. It verifies that the resolved database and key volumes keep the same identities, MongoDB collection counts and indexes remain unchanged, a value protected before restart can still be decrypted, and loopback readiness recovers. It does not alter provider feature flags, upgrade images, validate public TLS, or replace the separate backup/restore drill.
Record the host, operator, start/end time, release record checksum, resolved image IDs, preflight output and drill result in the deployment acceptance record. Also verify Docker starts at boot and perform a controlled Debian reboot before Gate A approval. After reboot, run the online preflight and inspect the Workspace health page; do not infer worker health solely from API readiness.
The supplied Docker `json-file` logs are size-capped to protect the small pilot disk, but container recreation removes that container's local log history. Before host acceptance, route GuestOps and Nginx logs to the site's durable restricted logging system, or use a reviewed Docker logging override backed by persistent systemd journal storage. Prove that operators can retrieve pre-recreation logs without exposing request credentials or OAuth callback query strings. Central retention and alerting are completed under milestone 11.

View File

@ -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.

View File

@ -0,0 +1,27 @@
# Gate B automation, identity and privacy acceptance
Run these reviews against the exact `0.2.0` candidate on the accepted HTTPS sandbox. Keep guest data, staff addresses, provider agreements, screenshots and raw reports in the restricted evidence store. Repository records contain opaque references only.
## Knowledge, AI and FAQ automation
Curate representative hotel-specific positive and negative FAQ cases. Use the bounded no-send evaluation and require zero false positives and zero false negatives. Review AI suggestions separately; escalations are valid outcomes, but no unsafe or unsupported draft may be approved. Exercise the FAQ stop control, train every pilot staff member, and name monitoring and rollback owners. Finish with FAQ mode off and PMS/payment writes disabled.
Copy `deploy/automation-acceptance.example.json`, complete the record, independently review its evidence, and run:
```sh
python3 deploy/automation_acceptance.py /secure/acceptance/automation-acceptance.json
```
## Identity, preferences and privacy
The hotel and privacy owners must approve explicit retention periods for conversations, audit history, backups and accounts. Name privacy, deletion and legal-hold owners and retain the deletion and hold procedures. Review Google processing for the mailbox pilot; either accept OpenAI processing or keep AI drafts disabled.
Exercise owner/staff boundaries, invitation and recovery lifecycle, trusted-proxy throttling, session invalidation, every owner-controlled preference, data inventory, deletion/retention handling, backup retention and audit evidence. Explicitly review the known absence of MFA, granular roles and self-service recovery; any accepted containment belongs in the final pilot decision.
Copy `deploy/identity-privacy-acceptance.example.json`, complete the record, independently review its evidence, and run:
```sh
python3 deploy/identity_privacy_acceptance.py /secure/acceptance/identity-privacy-acceptance.json
```
These validators check completeness and release binding. They do not make legal decisions, inspect provider agreements or implement deletion on behalf of the operator. Reference the retained records and validator output from `automation` and `identity-privacy` in the final pilot approval.

41
docs/google-acceptance.md Normal file
View File

@ -0,0 +1,41 @@
# Google mailbox acceptance
This runbook records real provider acceptance for one dedicated sandbox mailbox using synthetic messages only. It does not enable a production mailbox, FAQ automation, PMS writes or payment creation. Run it only after the exact release artifact has passed the Debian preflight and persistence drill.
Keep screenshots, Gmail message source, provider console records and request diagnostics in the restricted acceptance store. Do not commit mailbox addresses, authorization codes, refresh/access tokens, guest data, cookies, raw OAuth callbacks or raw evidence. The repository record contains only opaque evidence references.
## Preparation and stop conditions
Record the release commit, release-record SHA-256, image IDs, environment, operator, approver and maintenance window. Use two dedicated Google test accounts so the different-account rejection can be exercised without a personal account. Send only clearly synthetic messages between controlled recipients.
Start with AI drafts, staff sending, FAQ live mode, PMS writes and payment creation disabled. Confirm the OAuth redirect URI exactly matches the HTTPS sandbox. Stop immediately if a mailbox appears under the wrong hotel, a recipient differs from the review screen, an unapproved message is sent, a duplicate is observed, provider output exposes credentials, or an uncertain outcome is about to be blindly retried. Preserve evidence and follow the incident process.
## Required scenarios
| Record ID | Exercise | Passing evidence |
| --- | --- | --- |
| `oauth-readonly` | Connect sandbox mailbox A with sending disabled; inspect consent and saved health. | Only the expected read scope is granted, callback succeeds over HTTPS, mailbox identity is correct, and no credential appears in UI/log evidence. |
| `initial-import` | Send several synthetic plain-text inbox messages before and during the seven-day window, including an automated/list message. | Eligible messages import with correct sender, subject and reply identity; excluded automated mail and out-of-window mail do not appear. |
| `duplicate-import` | Restart the import pass twice and restart the worker during a paginated pass. | Each provider message appears once and the pass resumes without losing its window. |
| `same-account-reconnect` | Disconnect locally, reconnect mailbox A and inspect retained conversations/drafts. | Mailbox identity is retained, connection identity rotates, history remains, and synchronization resumes without duplicates. |
| `different-account-rejected` | Start reconnect for mailbox A but choose sandbox mailbox B. | The reconnect is rejected and mailbox A remains disconnected without B being attached to the hotel. |
| `provider-revocation` | Remove the app grant in Google's account controls, allow the worker to observe it, then reconnect A. | Health reports reconnection required without repeated provider calls; an owner reconnect restores synchronization. |
| `reviewed-send` | Enable server and hotel staff sending, reconnect for send consent, save a synthetic reply and approve its exact recipient/body. | One message appears in Gmail Sent and at the controlled recipient with the approved body, sender and stable message identity. |
| `gmail-threading` | Inspect the reviewed send in both Gmail accounts. | Gmail places it in the intended thread and message source contains the expected reply headers without CC/BCC. |
| `duplicate-approval` | Concurrently submit or repeat approval for the same imported message, then restart the worker. | Exactly one Gmail message exists; later approval/replay attempts are rejected or show the completed delivery. |
| `uncertain-send-reconciliation` | Use an approved, reviewed provider-test method to create or use an uncertain result; never induce it against uncontrolled recipients. | The item stays held, is not automatically resent, and “Verify in Gmail Sent” marks it sent only when the stable identity, sender, recipient and SENT label uniquely match. If the environment cannot safely induce uncertainty, this scenario remains unpassed. |
| `sending-stop-control` | Queue a reviewed synthetic reply, disable hotel sending before worker submission, and observe the result. | Nothing reaches Gmail and the item is rejected for staff action; re-enabling does not silently submit an old automatic approval. |
After each provider-side change, allow for the documented worker interval and capture timestamps in UTC. Treat Gmail acceptance as provider submission, not proof of final delivery; inspect the controlled recipient and any bounce separately.
## Record and approval
Copy `deploy/google-acceptance.example.json` to the restricted acceptance store outside the checkout. Replace all placeholders, change a scenario to `pass` only after reviewing its restricted evidence, and use opaque ticket or evidence IDs without email addresses. A different person should complete `acceptedBy` after checking all evidence and confirming the stop conditions did not occur.
Validate the completed record:
```sh
python3 deploy/google_acceptance.py /secure/acceptance/google-mailbox-release.json
```
The validator checks completeness, immutable release identifiers, ordered UTC timestamps, an HTTPS origin, all required passing scenarios and safe evidence references. It cannot inspect or prove the underlying evidence. Retain the record with the release and link its restricted location from the milestone tracker; do not mark milestone 12 accepted merely because the validator succeeds.

28
docs/incident-exercise.md Normal file
View File

@ -0,0 +1,28 @@
# Incident and rollback exercise
Run this exercise on the approved sandbox release with synthetic records only. Agree detection, containment and recovery targets before starting. The operator, incident commander and independent reviewer must be different people. Keep actual guest addresses, credentials, tokens, provider payloads and raw incident evidence in the restricted acceptance store.
## Gate B scenarios
| Record ID | Exercise | Passing evidence |
| --- | --- | --- |
| `alert-and-escalate` | Introduce an approved synthetic failure and use normal monitoring and support routes. | The alert is detected, the incident commander is engaged and timestamps meet the agreed detection target. |
| `disable-worker-writes` | Use the documented controls to stop worker-driven provider writes and FAQ automation. | Pending work does not reach a provider, controls remain effective across a worker restart and staff can identify the safe state. |
| `google-uncertain-send` | Simulate an interrupted or ambiguous Gmail submission. | Staff do not resend blindly, reconcile using the stable message ID and retain the disposition. |
| `restore-readiness` | Use the isolated restore procedure and inspect provider-facing records before enabling workers. | The restored system becomes ready, older approvals remain held and external effects are reconciled. |
| `image-rollback` | Deploy the retained prior image IDs using the rollback procedure without replacing MongoDB or key volumes. | The recorded images run, readiness and read-only smoke checks pass and persistent state remains available. |
| `controlled-recovery` | Recover the approved release after containment. | Monitoring is healthy, held operations are dispositioned and the exercise ends with writes disabled and FAQ mode off. |
For Gate C, also exercise `pms-ambiguous-write` and `payment-ambiguous-create`. In both cases, lose or interrupt the synthetic provider response and prove the operation is reconciled read-only without creating a replacement request or replaying the approval.
## Record and validation
Copy `deploy/incident-exercise.example.json` into the restricted acceptance store. Bind it to the same full release commit and release-record SHA-256 used by the deployment and pilot decision. Replace the example timestamps, people, targets and evidence references. Change a scenario to `pass` only after its evidence has been independently reviewed. The example is intentionally invalid because its scenarios are `not-run`.
For a Gate C exercise, set `targetGate` to `C` and add the PMS and payment scenario records. Validate the completed record with:
```sh
python3 deploy/incident_exercise.py /secure/acceptance/incident-exercise.json
```
The validator checks record completeness, release binding, scenario coverage, independent roles, timing targets and the safe final state. It does not inspect the referenced evidence, create an incident response capability or authorize a pilot. Retain its output and the record checksum, then reference them from `incident-support` in the pilot approval record.

View File

@ -35,3 +35,5 @@ Existing mailbox documents without a Version field remain compatible. Their conn
Automated fixtures cover pagination, duplicate imports, deleted messages, invalid grants, throttling, client configuration failures, reconnect account matching, missing read scope, cross-hotel ownership, stale workers, interrupted connections and queued-reply protection. MongoDB and HTTP tests exercise persistence, legacy documents, owner-only controls and preview isolation. These tests do not contact Google. Automated fixtures cover pagination, duplicate imports, deleted messages, invalid grants, throttling, client configuration failures, reconnect account matching, missing read scope, cross-hotel ownership, stale workers, interrupted connections and queued-reply protection. MongoDB and HTTP tests exercise persistence, legacy documents, owner-only controls and preview isolation. These tests do not contact Google.
With a dedicated test mailbox on the HTTPS sandbox, verify consent and callback configuration, initial import, disconnect, manual removal of Google access, reconnect, read-only and send scopes, retained drafts, and staff review of rejected approvals. Keep FAQ live sending and other external writes off until their separate acceptance procedures pass. Full Gmail thread aggregation, history-repair tooling and attachments remain future work. With a dedicated test mailbox on the HTTPS sandbox, verify consent and callback configuration, initial import, disconnect, manual removal of Google access, reconnect, read-only and send scopes, retained drafts, and staff review of rejected approvals. Keep FAQ live sending and other external writes off until their separate acceptance procedures pass. Full Gmail thread aggregation, history-repair tooling and attachments remain future work.
Use the [Google mailbox acceptance runbook](google-acceptance.md) and its validated restricted evidence record for the complete provider exercise. Fixture and record-validation tests do not replace that live acceptance.

View File

@ -2,6 +2,26 @@
The owner-only **Workspace health** page reports database reachability, the worker's last heartbeat, mailbox recovery counts and reply exceptions. A heartbeat older than three minutes is marked stale. It proves that the worker process recently reached MongoDB, not that every provider or job succeeded. Reply totals cover at most the latest 500 conversations in this hotel. The page does not verify backups. `/health/ready` returns only readiness status and HTTP 503 when the database cannot be reached. The owner-only **Workspace health** page reports database reachability, the worker's last heartbeat, mailbox recovery counts and reply exceptions. A heartbeat older than three minutes is marked stale. It proves that the worker process recently reached MongoDB, not that every provider or job succeeded. Reply totals cover at most the latest 500 conversations in this hotel. The page does not verify backups. `/health/ready` returns only readiness status and HTTP 503 when the database cannot be reached.
## Release evidence and rollback
Every non-pull-request CI build packages the API and worker images under the full Git commit SHA. The accompanying `release-record.json` binds the archive checksum, application version, commit, image references and immutable Docker image IDs. Retain both files together in restricted off-host release storage; the CI artifact is a transfer mechanism, not the permanent archive.
Before deployment, verify the archive against its record without loading it:
```sh
python3 - <<'PY'
import hashlib, json, pathlib
r = json.load(open('release-record.json', encoding='utf-8'))
p = pathlib.Path(r['artifact']['name'])
assert hashlib.sha256(p.read_bytes()).hexdigest() == r['artifact']['sha256']
print(r['commit'], r['version'], r['images'])
PY
```
Load the archive, verify each loaded image ID matches the record, set `GUESTOPS_API_IMAGE` and `GUESTOPS_WORKER_IMAGE` to the recorded full-SHA references, and run the deployment preflight. Record the CI run, commit, checksum and operator in the change ticket. A release tag is an approval marker; do not move or reuse an existing tag. The application and web versions must match before the record can be created.
For rollback, first disable worker-driven external writes and reconcile any sending, payment or PMS operation that may have completed since the prior release. Confirm the previous release archive and record are retained, verify its checksum and image IDs, take an encrypted backup, then select the previous recorded image references in `.env` and recreate only the API and worker. Do not roll back MongoDB or the key volume merely to change application images. Run the online preflight, readiness check and read-only smoke test before re-enabling the worker or provider writes. If a release introduced an incompatible data change, follow its release-specific recovery plan rather than starting an older image against newer data.
## Deployment preflight ## Deployment preflight
Run from the dedicated GuestOps checkout on Linux with Python 3.11 or later, Docker with Compose, and GnuPG installed. The tool supports the supplied three-service Compose deployment and the `guestops` database only. Docker access is administrator-equivalent; use the designated server operator account. Load the reviewed API, worker and MongoDB images first, configure `.env` with mode 600, and follow [deployment](deployment.md). Run from the dedicated GuestOps checkout on Linux with Python 3.11 or later, Docker with Compose, and GnuPG installed. The tool supports the supplied three-service Compose deployment and the `guestops` database only. Docker access is administrator-equivalent; use the designated server operator account. Load the reviewed API, worker and MongoDB images first, configure `.env` with mode 600, and follow [deployment](deployment.md).
@ -37,6 +57,40 @@ No other application or administrator may write to this database during the snap
Copy the encrypted file off-server to restricted storage after every successful backup. Retain the exact API, worker and MongoDB images with the release: an image tag alone can change, and the drill requires matching image IDs. Agree the backup schedule, retention and tolerated data loss before a hotel pilot. Scheduling, off-server transfer, deletion and alerting are operator responsibilities in this milestone; none is silently installed. Copy the encrypted file off-server to restricted storage after every successful backup. Retain the exact API, worker and MongoDB images with the release: an image tag alone can change, and the drill requires matching image IDs. Agree the backup schedule, retention and tolerated data loss before a hotel pilot. Scheduling, off-server transfer, deletion and alerting are operator responsibilities in this milestone; none is silently installed.
### Optional systemd schedule
The repository includes an opt-in daily systemd service and timer. They are templates, not automatically installed. The service assumes the reviewed checkout is `/srv/guestops` and stages encrypted files in `/var/backups/guestops`; review and change both unit files together if the host uses different paths.
Create the staging directory and environment file without storing a private key or passphrase on the server:
```sh
sudo install -d -m 700 /var/backups/guestops /var/lib/guestops-backup/gnupg /etc/guestops
sudo install -m 600 /dev/null /etc/guestops/backup.env
sudoedit /etc/guestops/backup.env
sudo env GNUPGHOME=/var/lib/guestops-backup/gnupg gpg --import /secure/path/recovery-public.asc
sudo env GNUPGHOME=/var/lib/guestops-backup/gnupg gpg --fingerprint
```
The file contains only these two settings. `BACKUP_RECIPIENT` is the verified 40-character public-key fingerprint already imported into the service account's GPG keyring.
```text
BACKUP_RECIPIENT=0123456789ABCDEF0123456789ABCDEF01234567
BACKUP_DIRECTORY=/var/backups/guestops
```
Review the unit sandbox against the host, copy `deploy/systemd/guestops-backup.service` and `.timer` to `/etc/systemd/system`, then validate and test before enabling:
```sh
sudo systemd-analyze verify /etc/systemd/system/guestops-backup.service /etc/systemd/system/guestops-backup.timer
sudo systemctl daemon-reload
sudo systemctl start guestops-backup.service
sudo systemctl status guestops-backup.service
sudo systemctl enable --now guestops-backup.timer
systemctl list-timers guestops-backup.timer
```
The timer deliberately causes the same brief maintenance interruption as a manual backup. `Persistent=true` runs a missed event after downtime, so choose and communicate the maintenance window. A successful unit only stages an encrypted file locally. Configure an independently monitored off-host transfer, verify the destination checksum, alert on both unit and transfer failure, and test the alert route. Do not add automatic deletion until retention, legal hold and recovery requirements have named owners.
Temporary plaintext files are held in private directories and removed on normal completion or exceptions. Process termination or power loss can leave temporary data, stopped services or TTL expiry disabled. After an interrupted run, inspect the dedicated project and remove only its identified abandoned temporary directory after securing any recovery material. Restore the recorded TTL setting (normally true) and restart the services: Temporary plaintext files are held in private directories and removed on normal completion or exceptions. Process termination or power loss can leave temporary data, stopped services or TTL expiry disabled. After an interrupted run, inspect the dedicated project and remove only its identified abandoned temporary directory after securing any recovery material. Restore the recorded TTL setting (normally true) and restart the services:
```sh ```sh

67
docs/pilot-release.md Normal file
View File

@ -0,0 +1,67 @@
# Supervised pilot, capacity and release approval
Milestone 18 is an evidence exercise against the exact approved release, not a feature toggle. Use synthetic data for capacity work and a separately approved, tightly supervised hotel cohort for the pilot. Keep provider writes and FAQ live mode disabled until their individual acceptance records are approved.
Before the pilot, complete the [Gate B automation, identity and privacy acceptance](gate-b-prerequisites.md) as well as the Google and desktop exercises. These reviews must use the same release identifiers as the final decision.
## Read-only capacity probe
The capacity probe logs in once with a dedicated sandbox staff account and sends bounded concurrent GET requests to readiness, hotel settings and cursor-paginated inbox endpoints. It never calls provider integrations, creates records, edits drafts or retains response bodies. Run it only during an approved sandbox window and monitor CPU, memory, MongoDB latency, disk, Nginx and application errors independently.
```sh
read -r -p 'Capacity account email: ' CAPACITY_EMAIL
read -r -s -p 'Capacity account password: ' CAPACITY_PASSWORD
export CAPACITY_EMAIL CAPACITY_PASSWORD
python3 deploy/capacity_probe.py \
--origin https://sandbox-guestops.futuresens.co.uk \
--requests 500 --concurrency 10 \
--release-commit FULL_40_CHARACTER_SHA \
--release-record-sha256 RELEASE_RECORD_SHA256 \
--output /secure/acceptance/capacity.json \
--confirm-sandbox
unset CAPACITY_EMAIL CAPACITY_PASSWORD
```
For the `0.2.0` Gate B candidate, the approved targets are concurrency 10, p95 latency at or below 500 ms, error rate at or below 1%, and at least 25% CPU and memory headroom on the documented four-core, 7.6 GiB host. The generated result reports HTTP observations, not a pass/fail claim. Repeat after a warm-up, investigate every error, and retain independently captured host metrics with the report. Hash both retained files for the approval record. Do not point the probe at a live hotel or increase its built-in bounds to simulate a denial of service.
## Five-business-day supervised pilot
Use one approved hotel and named hotel, technical and rollback owners. Start only after the prerequisite evidence below has passed. Keep PMS writes, payment creation and FAQ live mode disabled. Complete one daily review on each of five business days and stop for tenant leakage, credential exposure, data loss, an unapproved or duplicate send, an unreconciled uncertain send, failed rollback or loss of monitoring.
Copy `deploy/pilot-run.example.json` to the restricted evidence store and replace all placeholders. Resolve critical and high findings; lower-severity findings may be contained only with an owner, expiry and objective rollback trigger. Validate and hash the final record:
```sh
python3 deploy/pilot_run.py /secure/acceptance/pilot-run.json
sha256sum /secure/acceptance/pilot-run.json
```
The example deliberately fails while daily reviews are `not-run`. A structurally valid record does not substitute for the five elapsed business days or independent evidence review.
## Pilot exit record
The go/no-go record must bind all evidence to the same release commit and release-record checksum. Record named owners, dates, evidence locations, findings and explicit dispositions for:
- default-branch CI and immutable release archive;
- Debian preflight, HTTPS, persistence and controlled reboot;
- encrypted off-host backup and timed isolated restore;
- Google mailbox and reviewed-send acceptance;
- knowledge, AI and FAQ test-mode evaluation;
- identity, privacy, retention and audit review;
- inbox pagination, draft protection, timezone and desktop-parity acceptance;
- capacity targets, observed host headroom and expected pilot workload;
- support, rollback, provider-reconciliation and incident exercises;
- every pilot usability or security finding.
Approval requires separate named decisions from the hotel pilot owner and technical release owner. Gate C additionally requires independently accepted PMS and payment-provider evidence. A conditional approval must identify the containment, owner, expiry and rollback trigger; an unresolved finding is not silently converted into acceptance. Retain the signed decision with the release rather than committing guest, credential or incident data to this repository.
Copy `deploy/pilot-approval.example.json` into the restricted release store and complete it only after reviewing the referenced evidence. Approval schema version 2 binds the five-day pilot record and both the capacity report and independently captured host metrics. The example is intentionally invalid while its decision is `pending`. For a contained finding, record its named owner, future expiry and objective rollback trigger. Validate the completed record with:
```sh
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.

View File

@ -20,6 +20,9 @@ public sealed record AutoRuleInput(string Question,string KnowledgeId,bool Enabl
public sealed record AutoModeInput(string Mode,long Version,bool AcceptanceConfirmed); public sealed record AutoModeInput(string Mode,long Version,bool AcceptanceConfirmed);
public sealed record AutoTestInput(string Subject,string Body); public sealed record AutoTestInput(string Subject,string Body);
public sealed record AutoDecision(bool Matches,string Reason,string RuleId="",string KnowledgeId="",string Body=""); public sealed record AutoDecision(bool Matches,string Reason,string RuleId="",string KnowledgeId="",string Body="");
public sealed record AutoEvaluationCase(string Id,string Subject,string Body,bool ExpectedMatch,string ExpectedKnowledgeId="");
public sealed record AutoEvaluationInput(AutoEvaluationCase[] Cases);
public sealed record AutoEvaluationResult(string Id,bool Passed,bool ExpectedMatch,bool ActualMatch,string ExpectedKnowledgeId,string ActualKnowledgeId,string Reason);
public static class FaqMatcher public static class FaqMatcher
{ {
public static readonly string[] Questions=["What time is check in?","What time is check out?","Where can I park?","Is parking available?","What time is breakfast?","Do you have WiFi?","What is your address?"]; public static readonly string[] Questions=["What time is check in?","What time is check out?","Where can I park?","Is parking available?","What time is breakfast?","Do you have WiFi?","What is your address?"];
@ -52,6 +55,16 @@ public sealed class AutoReplyWork(IStore store,IConfiguration config)
{ {
public bool LiveConfigured=>config.GetValue<bool>("AutoReply:EnableLive"); public bool LiveConfigured=>config.GetValue<bool>("AutoReply:EnableLive");
public async Task<AutoDecision> Test(string hotel,string subject,string body)=>FaqMatcher.Evaluate(new Conversation{HotelId=hotel,Subject=subject,Body=body},await store.List<AutoReplyRule>(hotel),await store.List<KnowledgeEntry>(hotel)); public async Task<AutoDecision> Test(string hotel,string subject,string body)=>FaqMatcher.Evaluate(new Conversation{HotelId=hotel,Subject=subject,Body=body},await store.List<AutoReplyRule>(hotel),await store.List<KnowledgeEntry>(hotel));
public async Task<AutoEvaluationResult[]> Evaluate(string hotel,IEnumerable<AutoEvaluationCase> cases)
{
var rules=await store.List<AutoReplyRule>(hotel);var knowledge=await store.List<KnowledgeEntry>(hotel);
return cases.Select(item=>
{
var decision=FaqMatcher.Evaluate(new Conversation{HotelId=hotel,Subject=item.Subject,Body=item.Body},rules,knowledge);
var passed=decision.Matches==item.ExpectedMatch&&(!item.ExpectedMatch||item.ExpectedKnowledgeId.Length==0||decision.KnowledgeId==item.ExpectedKnowledgeId);
return new AutoEvaluationResult(item.Id,passed,item.ExpectedMatch,decision.Matches,item.ExpectedKnowledgeId,decision.KnowledgeId,decision.Reason);
}).ToArray();
}
public async Task Process(Conversation message) public async Task Process(Conversation message)
{ {
var hotel=await store.Get<Hotel>(message.HotelId,message.HotelId); var hotel=await store.Get<Hotel>(message.HotelId,message.HotelId);

View File

@ -14,6 +14,10 @@ public static class AutoReplyEndpoints
group.MapPost("/test",async(AutoTestInput input,HttpContext c,AutoReplyWork work)=>{ group.MapPost("/test",async(AutoTestInput input,HttpContext c,AutoReplyWork work)=>{
if(!Input.Text(input.Subject,0,200)||!Input.Text(input.Body,1,2000))return Results.BadRequest();return Results.Ok(await work.Test(Session.Hotel(c),input.Subject,input.Body)); if(!Input.Text(input.Subject,0,200)||!Input.Text(input.Body,1,2000))return Results.BadRequest();return Results.Ok(await work.Test(Session.Hotel(c),input.Subject,input.Body));
}).RequireAuthorization("Owner"); }).RequireAuthorization("Owner");
group.MapPost("/evaluate",async(AutoEvaluationInput input,HttpContext c,AutoReplyWork work)=>{
if(input.Cases is not {Length:>=1 and <=100} cases||cases.Select(x=>x.Id).Distinct(StringComparer.Ordinal).Count()!=cases.Length||cases.Any(x=>!Input.Text(x.Id,1,80)||!Input.Text(x.Subject,0,200)||!Input.Text(x.Body,1,2000)||x.ExpectedKnowledgeId.Length>80))return Results.BadRequest();
var results=await work.Evaluate(Session.Hotel(c),cases);return Results.Ok(new{total=results.Length,passed=results.Count(x=>x.Passed),falsePositives=results.Count(x=>!x.ExpectedMatch&&x.ActualMatch),falseNegatives=results.Count(x=>x.ExpectedMatch&&!x.ActualMatch),results});
}).RequireAuthorization("Owner");
group.MapPut("/rules/{question:int}",async(int question,AutoRuleInput input,HttpContext c,IStore store)=>{ group.MapPut("/rules/{question:int}",async(int question,AutoRuleInput input,HttpContext c,IStore store)=>{
if(question<0||question>=FaqMatcher.Questions.Length||input.Question!=FaqMatcher.Questions[question])return Results.BadRequest(); if(question<0||question>=FaqMatcher.Questions.Length||input.Question!=FaqMatcher.Questions[question])return Results.BadRequest();
var hotel=Session.Hotel(c);var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hotel+":"+question))).ToLowerInvariant()[..32]; var hotel=Session.Hotel(c);var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hotel+":"+question))).ToLowerInvariant()[..32];

View File

@ -0,0 +1,19 @@
using System.Text;
namespace GuestOps.Web;
public sealed record ConversationPage(IReadOnlyList<Conversation> Items,string? NextCursor);
public static class ConversationPaging
{
public static string Encode(Conversation item)=>Convert.ToBase64String(Encoding.UTF8.GetBytes(item.ReceivedAt.Ticks+"|"+item.Id)).TrimEnd('=').Replace('+','-').Replace('/','_');
public static bool TryDecode(string? value,out DateTime at,out string id)
{
at=default;id="";if(string.IsNullOrEmpty(value))return true;
try
{
if(value.Length>160)return false;var raw=value.Replace('-','+').Replace('_','/');raw=raw.PadRight((raw.Length+3)/4*4,'=');
var parts=Encoding.UTF8.GetString(Convert.FromBase64String(raw)).Split('|');
return parts.Length==2&&long.TryParse(parts[0],out var ticks)&&ticks>=DateTime.MinValue.Ticks&&ticks<=DateTime.MaxValue.Ticks&&System.Text.RegularExpressions.Regex.IsMatch(parts[1],"^[a-f0-9]{32}$")&&(at=new DateTime(ticks,DateTimeKind.Utc))!=default&&(id=parts[1]).Length>0;
}
catch{return false;}
}
}

View File

@ -22,8 +22,8 @@ public static class Operations
}); });
api.MapGet("/operations",async(HttpContext c,IStore store)=> api.MapGet("/operations",async(HttpContext c,IStore store)=>
{ {
var id=Session.Hotel(c);var boxes=await store.List<Mailbox>(id);var messages=await store.List<Conversation>(id);var seen=await store.WorkerLastSeen(); var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);var boxes=await store.List<Mailbox>(id);var messages=await store.List<Conversation>(id);var seen=await store.WorkerLastSeen();
return Results.Ok(new{checkedAt=DateTime.UtcNow,preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seen<DateTime.UtcNow.AddMinutes(-3)?"Stale":"Reporting"},mailboxes=new{total=boxes.Count,connected=boxes.Count(x=>x.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")}}); return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seen<DateTime.UtcNow.AddMinutes(-3)?"Stale":"Reporting"},mailboxes=new{total=boxes.Count,connected=boxes.Count(x=>x.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")}});
}).RequireAuthorization("Owner"); }).RequireAuthorization("Owner");
} }
} }

View File

@ -55,9 +55,9 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner"))); builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner")));
builder.Services.Configure<ForwardedHeadersOptions>(o => builder.Services.Configure<ForwardedHeadersOptions>(o =>
{ {
// Trust only HTTPS information from the configured host reverse proxy. // Trust scheme and client address only from the explicitly configured host proxy.
// Client IP forwarding remains disabled until per-client limits are introduced. // The rewritten RemoteIpAddress is used by login throttling below.
o.ForwardedHeaders = ForwardedHeaders.XForwardedProto; o.ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor;
o.ForwardLimit = 1; o.ForwardLimit = 1;
if (builder.Configuration["Proxy:KnownAddress"] is { Length: > 0 } address) if (builder.Configuration["Proxy:KnownAddress"] is { Length: > 0 } address)
o.KnownProxies.Add(IPAddress.Parse(address)); o.KnownProxies.Add(IPAddress.Parse(address));
@ -169,6 +169,7 @@ api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel); await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel);
}).RequireAuthorization("Owner"); }).RequireAuthorization("Owner");
api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))); api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt)));
api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."}));
api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound()); api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound());
api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) => api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) =>
{ {
@ -204,7 +205,7 @@ api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContex
await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item); await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item);
}).RequireAuthorization("Owner"); }).RequireAuthorization("Owner");
api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Activity>(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))); api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Activity>(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100)));
api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, items = (await store.List<Mailbox>(Session.Hotel(c))).Select(MailboxManagement.View) })); api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get<Hotel>(hotel,hotel))?.Timezone??"UTC", items = (await store.List<Mailbox>(hotel)).Select(MailboxManagement.View) }); });
api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, GoogleMailbox google, AiDrafts ai) => api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, GoogleMailbox google, AiDrafts ai) =>
{ {
if ((input.AiDraftsEnabled && (preview || !ai.Configured)) || (input.StaffSendingEnabled && (preview || !google.SendingConfigured))) return Results.BadRequest(new { error = "The administrator must configure this capability first." }); if ((input.AiDraftsEnabled && (preview || !ai.Configured)) || (input.StaffSendingEnabled && (preview || !google.SendingConfigured))) return Results.BadRequest(new { error = "The administrator must configure this capability first." });

View File

@ -11,6 +11,7 @@ public interface IStore
Task<DateTime?> WorkerLastSeen(); Task<DateTime?> WorkerLastSeen();
Task RecordWorkerHeartbeat(); Task RecordWorkerHeartbeat();
Task<List<T>> List<T>(string hotel) where T : TenantDocument; Task<List<T>> List<T>(string hotel) where T : TenantDocument;
Task<ConversationPage> ConversationPage(string hotel,DateTime? before,string beforeId,int limit);
Task<T?> Get<T>(string hotel, string id) where T : TenantDocument; Task<T?> Get<T>(string hotel, string id) where T : TenantDocument;
Task Insert<T>(T document) where T : TenantDocument; Task Insert<T>(T document) where T : TenantDocument;
Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument; Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument;
@ -78,6 +79,12 @@ public sealed class MongoStore : IStore
if (typeof(T) == typeof(PmsChange) || typeof(T) == typeof(PaymentRequest)) query = query.Sort(Builders<T>.Sort.Descending("UpdatedAt")); if (typeof(T) == typeof(PmsChange) || typeof(T) == typeof(PaymentRequest)) query = query.Sort(Builders<T>.Sort.Descending("UpdatedAt"));
return query.Limit(500).ToListAsync(); return query.Limit(500).ToListAsync();
} }
public async Task<ConversationPage> ConversationPage(string hotel,DateTime? before,string beforeId,int limit)
{
var filter=Scope<Conversation>(hotel);if(before!=null)filter&=Builders<Conversation>.Filter.Lt(x=>x.ReceivedAt,before.Value)|(Builders<Conversation>.Filter.Eq(x=>x.ReceivedAt,before.Value)&Builders<Conversation>.Filter.Lt(x=>x.Id,beforeId));
var rows=await Collection<Conversation>().Find(filter).SortByDescending(x=>x.ReceivedAt).ThenByDescending(x=>x.Id).Limit(limit+1).ToListAsync();
var more=rows.Count>limit;if(more)rows.RemoveAt(rows.Count-1);return new(rows,more?ConversationPaging.Encode(rows[^1]):null);
}
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().Find(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync(); public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().Find(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync();
public Task Insert<T>(T document) where T : TenantDocument public Task Insert<T>(T document) where T : TenantDocument
{ {
@ -179,6 +186,11 @@ public sealed class PreviewStore : IStore
static string Json<T>(T value) => System.Text.Json.JsonSerializer.Serialize(value); static string Json<T>(T value) => System.Text.Json.JsonSerializer.Serialize(value);
public Task Initialize() => Task.CompletedTask; public Task Initialize() => Task.CompletedTask;
public Task<List<T>> List<T>(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone<T>(x.Value)).Where(x => x.HotelId == hotel).ToList()); public Task<List<T>> List<T>(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone<T>(x.Value)).Where(x => x.HotelId == hotel).ToList());
public async Task<ConversationPage> ConversationPage(string hotel,DateTime? before,string beforeId,int limit)
{
var query=(await List<Conversation>(hotel)).OrderByDescending(x=>x.ReceivedAt).ThenByDescending(x=>x.Id).Where(x=>before==null||x.ReceivedAt<before||x.ReceivedAt==before&&string.CompareOrdinal(x.Id,beforeId)<0).Take(limit+1).ToList();
var more=query.Count>limit;if(more)query.RemoveAt(query.Count-1);return new(query,more?ConversationPaging.Encode(query[^1]):null);
}
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => (await List<T>(hotel)).SingleOrDefault(x => x.Id == id); public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => (await List<T>(hotel)).SingleOrDefault(x => x.Id == id);
public Task Insert<T>(T document) where T : TenantDocument { if (!rows.TryAdd(Key<T>(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; } public Task Insert<T>(T document) where T : TenantDocument { if (!rows.TryAdd(Key<T>(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; }
public Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument public Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument

View File

@ -22,6 +22,8 @@ static class AutoReplyTests
check("Unsupported subject context stays with staff",!(await work.Test(hotel.Id,"I need help with my insulin","Is parking available?")).Matches); check("Unsupported subject context stays with staff",!(await work.Test(hotel.Id,"I need help with my insulin","Is parking available?")).Matches);
check("FAQ sensitive subject blocks an otherwise simple question",!(await work.Test(hotel.Id,"Refund for cancelled booking","Is parking available?")).Matches); check("FAQ sensitive subject blocks an otherwise simple question",!(await work.Test(hotel.Id,"Refund for cancelled booking","Is parking available?")).Matches);
check("FAQ answer cannot cross hotel boundary",!(await work.Test("foreign","Parking","Is parking available?")).Matches); check("FAQ answer cannot cross hotel boundary",!(await work.Test("foreign","Parking","Is parking available?")).Matches);
var evaluation=await work.Evaluate(hotel.Id,new[]{new AutoEvaluationCase("allowed","Parking","Is parking available?",true,answer.Id),new AutoEvaluationCase("extra-request","Parking","Is parking available? Also cancel my stay.",false)});
check("FAQ batch evaluation reports expected matches and exclusions",evaluation.Length==2&&evaluation.All(x=>x.Passed)&&evaluation[0].ActualKnowledgeId==answer.Id);
var m=await Process(Message());check("FAQ test mode records a match without delivery or quota use",m.AutoReplyMatched&&m.Delivery==null&&(await store.List<AutoReplyClaim>(hotel.Id)).Count==0); var m=await Process(Message());check("FAQ test mode records a match without delivery or quota use",m.AutoReplyMatched&&m.Delivery==null&&(await store.List<AutoReplyClaim>(hotel.Id)).Count==0);
var v=answer.Version;answer.Version++;await store.Replace(hotel.Id,answer.Id,v,answer);check("Changed approved answer invalidates FAQ rule",!(await work.Test(hotel.Id,"Parking","Is parking available?")).Matches); var v=answer.Version;answer.Version++;await store.Replace(hotel.Id,answer.Id,v,answer);check("Changed approved answer invalidates FAQ rule",!(await work.Test(hotel.Id,"Parking","Is parking available?")).Matches);
v=rule.Version;rule.KnowledgeVersion=answer.Version;rule.Version++;await store.Replace(hotel.Id,rule.Id,v,rule); v=rule.Version;rule.KnowledgeVersion=answer.Version;rule.Version++;await store.Replace(hotel.Id,rule.Id,v,rule);

View File

@ -36,6 +36,11 @@ try
await store.Import(row); await store.Import(row); await store.Import(row); await store.Import(row);
Check("Duplicate email import is idempotent", (await store.List<Conversation>(a.Id)).Count == 1); Check("Duplicate email import is idempotent", (await store.List<Conversation>(a.Id)).Count == 1);
Check("Inbox queries enforce hotel boundary", (await store.List<Conversation>(b.Id)).Count == 0); Check("Inbox queries enforce hotel boundary", (await store.List<Conversation>(b.Id)).Count == 0);
var pagingHotel=new Hotel{Name="Paging hotel"};pagingHotel.HotelId=pagingHotel.Id;await store.Insert(pagingHotel);var pagingNow=DateTime.UtcNow;var pagingAt=new DateTime(pagingNow.Ticks-pagingNow.Ticks%TimeSpan.TicksPerMillisecond,DateTimeKind.Utc);
foreach(var id in new[]{"00000000000000000000000000000003","00000000000000000000000000000002","00000000000000000000000000000001"})await store.Insert(new Conversation{Id=id,HotelId=pagingHotel.Id,ReceivedAt=pagingAt,Subject=id});
var firstPage=await store.ConversationPage(pagingHotel.Id,null,"",2);Check("Conversation page has stable bounded cursor",firstPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000003","00000000000000000000000000000002"})&&firstPage.NextCursor!=null);
Check("Conversation cursor round-trips safely",ConversationPaging.TryDecode(firstPage.NextCursor,out var pageAt,out var pageId)&&pageAt==pagingAt&&pageId=="00000000000000000000000000000002"&&!ConversationPaging.TryDecode("not-a-cursor",out _,out _));
var secondPage=await store.ConversationPage(pagingHotel.Id,pageAt,pageId,2);Check("Conversation pagination has no overlap or tenant leakage",secondPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000001"})&&secondPage.NextCursor==null);
var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 }; var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 };
Check("Settings update succeeds with current version", await store.Replace(a.Id,a.Id,0,altered)); Check("Settings update succeeds with current version", await store.Replace(a.Id,a.Id,0,altered));
Check("Concurrent stale edit rejected", !await store.Replace(a.Id,a.Id,0,altered)); Check("Concurrent stale edit rejected", !await store.Replace(a.Id,a.Id,0,altered));
@ -88,14 +93,14 @@ try
var h1=await Read(one,"/api/hotel");var h2=await Read(two,"/api/hotel"); var h1=await Read(one,"/api/hotel");var h2=await Read(two,"/api/hotel");
Check("Different preview sessions have different hotels", h1.GetProperty("id").GetString()!=h2.GetProperty("id").GetString()); Check("Different preview sessions have different hotels", h1.GetProperty("id").GetString()!=h2.GetProperty("id").GetString());
var health=await Read(one,"/api/operations"); var health=await Read(one,"/api/operations");
Check("Health overview is hotel scoped and labels preview worker",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview"); Check("Health overview is hotel scoped and carries hotel timezone",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview"&&health.GetProperty("timeZone").GetString()=="Europe/London");
var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString(); var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString();
Check("Guessed message ID cannot be edited across hotels", (await two.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="stolen",version=0})).StatusCode==HttpStatusCode.NotFound); Check("Guessed message ID cannot be edited across hotels", (await two.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="stolen",version=0})).StatusCode==HttpStatusCode.NotFound);
Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode); Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode);
Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict); Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict);
Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode); Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode);
var boxes=await Read(one,"/api/mailboxes");var boxId=boxes.GetProperty("items")[0].GetProperty("id").GetString(); var boxes=await Read(one,"/api/mailboxes");var boxId=boxes.GetProperty("items")[0].GetProperty("id").GetString();
Check("Mailbox health includes recovery state without secrets",boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken")); Check("Mailbox health includes timezone and recovery state without secrets",boxes.GetProperty("timeZone").GetString()=="Europe/London"&&boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken"));
foreach(var action in new[]{"disconnect","reconnect","retry"})Check("Other hotel cannot "+action+" a mailbox",(await two.PostAsJsonAsync($"/api/mailboxes/{boxId}/{action}",new{version=0})).StatusCode==HttpStatusCode.NotFound); foreach(var action in new[]{"disconnect","reconnect","retry"})Check("Other hotel cannot "+action+" a mailbox",(await two.PostAsJsonAsync($"/api/mailboxes/{boxId}/{action}",new{version=0})).StatusCode==HttpStatusCode.NotFound);
Check("Preview recovery cannot change real Google state",(await one.PostAsJsonAsync($"/api/mailboxes/{boxId}/reconnect",new{version=0})).StatusCode==HttpStatusCode.BadRequest); Check("Preview recovery cannot change real Google state",(await one.PostAsJsonAsync($"/api/mailboxes/{boxId}/reconnect",new{version=0})).StatusCode==HttpStatusCode.BadRequest);
Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest); Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest);

View File

@ -0,0 +1,26 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("capacity_probe", Path(__file__).resolve().parents[1] / "deploy" / "capacity_probe.py")
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
class CapacityProbeTests(unittest.TestCase):
def test_summary_reports_failures_and_nearest_rank_latency(self):
report = probe.summarize([(True, 10), (True, 20), (False, 30), (True, 40), (True, 100)], 2)
self.assertEqual(report["requests"], 5)
self.assertEqual(report["successes"], 4)
self.assertEqual(report["failures"], 1)
self.assertEqual(report["errorRate"], 0.2)
self.assertEqual(report["latencyMs"], {"median": 30, "p95": 100, "maximum": 100})
def test_summary_rejects_empty_results(self):
with self.assertRaisesRegex(ValueError, "At least one"):
probe.summarize([], 1)
if __name__ == "__main__":
unittest.main()

View File

@ -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()

View File

@ -0,0 +1,85 @@
import importlib.util
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
def module(name):
spec = importlib.util.spec_from_file_location(name, ROOT / "deploy" / f"{name}.py")
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
return result
automation = module("automation_acceptance")
privacy = module("identity_privacy_acceptance")
def base(system):
return {
"schemaVersion": 1, "system": system, "targetGate": "B",
"releaseCommit": "a" * 40, "releaseRecordSha256": "b" * 64,
"environment": "https://sandbox-guestops.futuresens.co.uk",
"operator": "Acceptance operator", "reviewedBy": "Independent reviewer",
"startedAt": "2026-10-01T09:00:00Z", "endedAt": "2026-10-01T10:00:00Z", "reviewedAt": "2026-10-01T11:00:00Z",
}
def automation_record():
record = base("guestops-automation-acceptance")
record.update({
"dataClassification": "synthetic-only",
"faqEvaluation": {"positiveCases": 20, "negativeCases": 20, "falsePositives": 0, "falseNegatives": 0, "reportSha256": "c" * 64},
"aiEvaluation": {"casesReviewed": 20, "unsafeDraftsApproved": 0, "reportSha256": "d" * 64},
"staffTrained": 3, "monitoringOwner": "Monitoring owner", "rollbackOwner": "Rollback owner",
"scenarios": [{"id": item, "status": "pass", "evidence": ["restricted-" + item]} for item in sorted(automation.SCENARIOS)],
"postAcceptanceState": {"faqMode": "off", "pmsWrites": "disabled", "paymentCreation": "disabled"},
})
return record
def privacy_record():
record = base("guestops-identity-privacy")
record.update({
"retention": {"conversationDays": 365, "auditDays": 730, "backupDays": 30, "accountDays": 730,
"privacyOwner": "Privacy owner", "deletionOwner": "Deletion owner", "legalHoldOwner": "Legal hold owner",
"deletionProcedure": "restricted-deletion", "legalHoldProcedure": "restricted-legal-hold"},
"providers": {"google": {"status": "accepted", "evidence": ["restricted-google"]},
"openai": {"status": "disabled", "evidence": ["restricted-openai-decision"]}},
"preferencesReviewed": sorted(privacy.PREFERENCES),
"scenarios": [{"id": item, "status": "pass", "evidence": ["restricted-" + item]} for item in sorted(privacy.SCENARIOS)],
})
return record
class GateBAcceptanceTests(unittest.TestCase):
def test_complete_records_pass(self):
automation.validate(automation_record())
privacy.validate(privacy_record())
def test_automation_requires_zero_faq_errors_and_training(self):
record = automation_record();record["faqEvaluation"]["falsePositives"] = 1
with self.assertRaisesRegex(ValueError, "zero false positives"):
automation.validate(record)
record = automation_record();record["staffTrained"] = 0
with self.assertRaisesRegex(ValueError, "staff member"):
automation.validate(record)
def test_privacy_requires_retention_and_google_decisions(self):
record = privacy_record();record["retention"]["conversationDays"] = 0
with self.assertRaisesRegex(ValueError, "conversationDays"):
privacy.validate(record)
record = privacy_record();record["providers"]["google"]["status"] = "pending"
with self.assertRaisesRegex(ValueError, "Google processing"):
privacy.validate(record)
def test_privacy_requires_exact_preference_coverage(self):
record = privacy_record();record["preferencesReviewed"].pop()
with self.assertRaisesRegex(ValueError, "exact owner-controlled"):
privacy.validate(record)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,67 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("google_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "google_acceptance.py")
acceptance = importlib.util.module_from_spec(spec)
spec.loader.exec_module(acceptance)
def valid_report():
return {
"schemaVersion": 1,
"system": "google-mailbox",
"releaseCommit": "a" * 40,
"releaseRecordSha256": "b" * 64,
"environment": "https://sandbox-guestops.futuresens.co.uk",
"mailboxLabel": "sandbox mailbox A",
"operator": "Test operator",
"startedAt": "2026-09-29T10:00:00Z",
"endedAt": "2026-09-29T11:00:00Z",
"acceptedAt": "2026-09-29T12:00:00Z",
"acceptedBy": "Test approver",
"scenarios": [
{"id": scenario, "status": "pass", "evidence": [f"restricted-ticket-{index}"]}
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
],
}
class GoogleAcceptanceTests(unittest.TestCase):
def test_complete_record_is_accepted(self):
acceptance.validate(valid_report())
def test_missing_or_unpassed_scenario_is_rejected(self):
report = valid_report()
report["scenarios"].pop()
with self.assertRaisesRegex(ValueError, "exact required scenario"):
acceptance.validate(report)
report = valid_report()
report["scenarios"][0]["status"] = "not-run"
with self.assertRaisesRegex(ValueError, "has not passed"):
acceptance.validate(report)
def test_record_rejects_email_addresses_and_insecure_origin(self):
report = valid_report()
report["mailboxLabel"] = "real-address@example.invalid"
with self.assertRaisesRegex(ValueError, "non-email alias"):
acceptance.validate(report)
report = valid_report()
report["environment"] = "http://sandbox.example.invalid/path"
with self.assertRaisesRegex(ValueError, "HTTPS origin"):
acceptance.validate(report)
def test_record_rejects_bad_timestamps_and_evidence(self):
report = valid_report()
report["acceptedAt"] = "2026-09-29T09:00:00Z"
with self.assertRaisesRegex(ValueError, "out of order"):
acceptance.validate(report)
report = valid_report()
report["scenarios"][0]["evidence"] = ["guest@example.invalid"]
with self.assertRaisesRegex(ValueError, "invalid evidence"):
acceptance.validate(report)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,78 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("incident_exercise", Path(__file__).resolve().parents[1] / "deploy" / "incident_exercise.py")
exercise = importlib.util.module_from_spec(spec)
spec.loader.exec_module(exercise)
def valid_record(gate="B"):
required = exercise.GATE_C_SCENARIOS if gate == "C" else exercise.GATE_B_SCENARIOS
return {
"schemaVersion": 1,
"system": "guestops-incident-exercise",
"targetGate": gate,
"dataClassification": "synthetic-only",
"releaseCommit": "a" * 40,
"releaseRecordSha256": "b" * 64,
"environment": "https://sandbox-guestops.futuresens.co.uk",
"operator": "Exercise operator",
"incidentCommander": "Incident commander",
"reviewedBy": "Independent reviewer",
"startedAt": "2026-09-29T09:00:00Z",
"endedAt": "2026-09-29T10:00:00Z",
"reviewedAt": "2026-09-29T11:00:00Z",
"targetsMinutes": {"detection": 10, "containment": 20, "recovery": 60},
"observedMinutes": {"detection": 5, "containment": 15, "recovery": 45},
"scenarios": [
{"id": scenario, "status": "pass", "evidence": [f"restricted-ticket-{index}"]}
for index, scenario in enumerate(sorted(required), 1)
],
"postExerciseState": {
"externalWrites": "disabled", "faqMode": "off", "unresolvedOperations": 0,
},
}
class IncidentExerciseTests(unittest.TestCase):
def test_gate_b_and_c_complete_records_pass(self):
exercise.validate(valid_record("B"))
exercise.validate(valid_record("C"))
def test_gate_specific_scenarios_are_required(self):
record = valid_record("C")
record["scenarios"].pop()
with self.assertRaisesRegex(ValueError, "exact incident scenario"):
exercise.validate(record)
def test_timings_must_meet_preagreed_targets(self):
record = valid_record()
record["observedMinutes"]["containment"] = 21
with self.assertRaisesRegex(ValueError, "Observed containment"):
exercise.validate(record)
record = valid_record()
record["targetsMinutes"]["recovery"] = True
with self.assertRaisesRegex(ValueError, "recovery target"):
exercise.validate(record)
def test_independent_people_and_safe_evidence_are_required(self):
record = valid_record()
record["reviewedBy"] = record["operator"]
with self.assertRaisesRegex(ValueError, "different people"):
exercise.validate(record)
record = valid_record()
record["scenarios"][0]["evidence"] = ["guest@example.invalid"]
with self.assertRaisesRegex(ValueError, "safe opaque"):
exercise.validate(record)
def test_safe_post_exercise_state_is_required(self):
record = valid_record()
record["postExerciseState"]["externalWrites"] = "enabled"
with self.assertRaisesRegex(ValueError, "writes disabled"):
exercise.validate(record)
if __name__ == "__main__":
unittest.main()

View File

@ -16,6 +16,65 @@ spec.loader.exec_module(ops)
class ArchiveTests(unittest.TestCase): class ArchiveTests(unittest.TestCase):
def test_persistence_layout_resolves_shared_keys_and_database(self):
config = {
"services": {
"api": {"volumes": [{"type": "volume", "source": "app-keys", "target": "/var/lib/guestops/keys"}]},
"worker": {"volumes": [{"type": "volume", "source": "app-keys", "target": "/var/lib/guestops/keys"}]},
"mongo": {"volumes": [{"type": "volume", "source": "mongo-data", "target": "/data/db"}]},
},
"volumes": {
"app-keys": {"name": "guestops_app-keys"},
"mongo-data": {"name": "guestops_mongo-data"},
},
}
self.assertEqual(ops.persistence_layout(config), {"keys": "guestops_app-keys", "database": "guestops_mongo-data"})
def test_persistence_layout_rejects_anonymous_or_split_keys(self):
config = {
"services": {
"api": {"volumes": [{"type": "volume", "source": "api-keys", "target": "/var/lib/guestops/keys"}]},
"worker": {"volumes": [{"type": "volume", "source": "worker-keys", "target": "/var/lib/guestops/keys"}]},
"mongo": {"volumes": [{"type": "volume", "source": "mongo-data", "target": "/data/db"}]},
},
"volumes": {"api-keys": {}, "worker-keys": {}, "mongo-data": {}},
}
with self.assertRaisesRegex(RuntimeError, "key volumes differ"):
ops.persistence_layout(config)
config["services"]["worker"]["volumes"][0] = {"type": "volume", "target": "/var/lib/guestops/keys"}
with self.assertRaisesRegex(RuntimeError, "named persistent volume"):
ops.persistence_layout(config)
def test_persistence_drill_requires_explicit_restart_confirmation(self):
with self.assertRaisesRegex(RuntimeError, "confirm-restart"):
ops.persistence_drill(type("Args", (), {"confirm_restart": False})())
def test_persistence_drill_restarts_then_recreates_stateless_services(self):
compose_calls = []
inventory = json.dumps({"bytes": 1, "collections": {"hotels": {"count": 1, "indexes": []}}}).encode()
def compose(*args, **kwargs):
compose_calls.append(args)
return b"a" * 30 if args[-1] == "--backup-probe" else b""
def mongo(script):
return inventory if "storageSize" in script else b""
with patch.object(ops, "configuration", return_value={}), \
patch.object(ops, "persistence_layout", return_value={"keys": "keys", "database": "data"}), \
patch.object(ops, "volume_identity", side_effect=lambda name: {"name": name, "driver": "local", "scope": "local"}), \
patch.object(ops, "compose", side_effect=compose), \
patch.object(ops, "mongo", side_effect=mongo), \
patch.object(ops, "wait_for", side_effect=lambda action, message: action()), \
patch.object(ops, "readiness"):
ops.persistence_drill(type("Args", (), {"confirm_restart": True})())
self.assertIn(("restart", "-t", "150", "mongo"), compose_calls)
self.assertIn(("restart", "-t", "150", "api", "worker"), compose_calls)
self.assertIn(("up", "-d", "--no-build", "--force-recreate", "api", "worker"), compose_calls)
self.assertTrue(any("--verify-backup-probe" in call for call in compose_calls))
def test_empty_provider_templates_are_not_secret_configuration(self): def test_empty_provider_templates_are_not_secret_configuration(self):
for section, target in (("Pms", "/run/guestops/pms.json"), ("Payments", "/run/guestops/payments.json")): for section, target in (("Pms", "/run/guestops/pms.json"), ("Payments", "/run/guestops/payments.json")):
self.assertFalse(ops.provider_configured({section: {"Hotels": {}}}, target)) self.assertFalse(ops.provider_configured({section: {"Hotels": {}}}, target))
@ -67,6 +126,36 @@ class ArchiveTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "confirm-maintenance"): with self.assertRaisesRegex(RuntimeError, "confirm-maintenance"):
ops.backup(type("Args", (), {"confirm_maintenance": False})()) ops.backup(type("Args", (), {"confirm_maintenance": False})())
def test_scheduled_backup_requires_explicit_maintenance(self):
with self.assertRaisesRegex(RuntimeError, "confirm-maintenance"):
ops.scheduled_backup(type("Args", (), {"confirm_maintenance": False})())
@unittest.skipUnless(os.name == "posix", "Linux file permission semantics")
def test_scheduled_backup_uses_private_external_directory_and_environment(self):
with tempfile.TemporaryDirectory() as parent:
directory = Path(parent) / "backups"
directory.mkdir(mode=0o700)
captured = []
with patch.dict(os.environ, {"BACKUP_RECIPIENT": "A" * 40, "BACKUP_DIRECTORY": str(directory)}), \
patch.object(ops, "ROOT", Path(parent) / "checkout"), \
patch.object(ops, "backup", side_effect=lambda args: captured.append(args)), \
patch.object(ops.time, "strftime", return_value="20260929T020000Z"):
ops.scheduled_backup(type("Args", (), {"confirm_maintenance": True})())
self.assertEqual(captured[0].recipient, "A" * 40)
self.assertEqual(Path(captured[0].output), directory / "guestops-20260929T020000Z.tar.gpg")
self.assertTrue(captured[0].confirm_maintenance)
@unittest.skipUnless(os.name == "posix", "Linux file permission semantics")
def test_scheduled_backup_rejects_checkout_directory(self):
with tempfile.TemporaryDirectory() as parent:
checkout = Path(parent) / "checkout"
directory = checkout / "backups"
directory.mkdir(parents=True, mode=0o700)
with patch.dict(os.environ, {"BACKUP_RECIPIENT": "A" * 40, "BACKUP_DIRECTORY": str(directory)}), \
patch.object(ops, "ROOT", checkout):
with self.assertRaisesRegex(RuntimeError, "outside the application checkout"):
ops.scheduled_backup(type("Args", (), {"confirm_maintenance": True})())
@unittest.skipUnless(os.name == "posix", "Linux file permission semantics") @unittest.skipUnless(os.name == "posix", "Linux file permission semantics")
def test_dump_failure_restarts_services_and_ttl(self): def test_dump_failure_restarts_services_and_ttl(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:

View File

@ -0,0 +1,82 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("pilot_approval", Path(__file__).resolve().parents[1] / "deploy" / "pilot_approval.py")
approval = importlib.util.module_from_spec(spec)
spec.loader.exec_module(approval)
def valid_record(gate="B"):
ids = approval.GATE_C if gate == "C" else approval.GATE_B
return {
"schemaVersion": 2, "targetGate": gate, "decision": "approved",
"releaseCommit": "a" * 40, "releaseRecordSha256": "b" * 64,
"decidedAt": "2026-09-29T12:00:00Z",
"approvals": {
"hotelOwner": {"name": "Hotel owner", "approvedAt": "2026-09-29T11:00:00Z"},
"technicalOwner": {"name": "Technical owner", "approvedAt": "2026-09-29T11:30:00Z"},
},
"capacity": {"reportSha256": "c" * 64, "hostMetricsSha256": "d" * 64, "targetConcurrency": 10, "observedConcurrency": 10, "targetP95Ms": 500, "observedP95Ms": 250, "targetErrorRate": 0.01, "observedErrorRate": 0, "targetCpuHeadroomPercent": 25, "observedCpuHeadroomPercent": 40, "targetMemoryHeadroomPercent": 25, "observedMemoryHeadroomPercent": 35},
"pilot": {"recordSha256": "e" * 64, "businessDaysObserved": 5, "hotelsObserved": 1, "stopConditionsObserved": 0, "unresolvedFindings": 0},
"evidence": [{"id": item, "status": "pass", "references": ["restricted-ticket-" + item]} for item in sorted(ids)],
}
class PilotApprovalTests(unittest.TestCase):
def test_gate_b_and_c_complete_records_pass(self):
approval.validate(valid_record("B"))
approval.validate(valid_record("C"))
def test_pending_or_missing_evidence_fails(self):
record = valid_record();record["decision"] = "pending"
with self.assertRaisesRegex(ValueError, "explicit approved"):
approval.validate(record)
record = valid_record();record["evidence"].pop()
with self.assertRaisesRegex(ValueError, "exact evidence"):
approval.validate(record)
def test_capacity_must_meet_preapproved_targets(self):
record = valid_record();record["capacity"]["observedP95Ms"] = 501
with self.assertRaisesRegex(ValueError, "observedP95Ms"):
approval.validate(record)
record = valid_record();record["capacity"]["observedConcurrency"] = 9
with self.assertRaisesRegex(ValueError, "Observed concurrency"):
approval.validate(record)
record = valid_record();record["capacity"]["targetConcurrency"] = True
with self.assertRaisesRegex(ValueError, "Observed concurrency"):
approval.validate(record)
record = valid_record();record["capacity"]["targetErrorRate"] = 2
with self.assertRaisesRegex(ValueError, "ratio"):
approval.validate(record)
record = valid_record();record["capacity"]["observedCpuHeadroomPercent"] = 24
with self.assertRaisesRegex(ValueError, "cpu headroom"):
approval.validate(record)
def test_completed_five_day_single_hotel_pilot_is_required(self):
record = valid_record();record["pilot"]["businessDaysObserved"] = 4
with self.assertRaisesRegex(ValueError, "five business days"):
approval.validate(record)
record = valid_record();record["pilot"]["stopConditionsObserved"] = 1
with self.assertRaisesRegex(ValueError, "stop condition"):
approval.validate(record)
def test_approvers_must_be_separate_people_without_email_addresses(self):
record = valid_record();record["approvals"]["technicalOwner"]["name"] = "Hotel owner"
with self.assertRaisesRegex(ValueError, "different people"):
approval.validate(record)
record = valid_record();record["approvals"]["hotelOwner"]["name"] = "owner@example.invalid"
with self.assertRaisesRegex(ValueError, "without an email"):
approval.validate(record)
def test_containment_requires_owner_future_expiry_and_trigger(self):
record = valid_record();item = record["evidence"][0];item["status"] = "contained"
with self.assertRaisesRegex(ValueError, "containment details"):
approval.validate(record)
item["containment"] = {"owner": "Release owner", "expiresAt": "2026-10-10T12:00:00Z", "rollbackTrigger": "Rollback if the contained condition occurs."}
approval.validate(record)
if __name__ == "__main__":
unittest.main()

59
tests/test_pilot_run.py Normal file
View File

@ -0,0 +1,59 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location("pilot_run", Path(__file__).resolve().parents[1] / "deploy" / "pilot_run.py")
pilot = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pilot)
def valid_record():
days = [f"2026-10-{day:02d}" for day in range(5, 10)]
return {
"schemaVersion": 1, "system": "guestops-supervised-pilot", "targetGate": "B",
"releaseCommit": "a" * 40, "releaseRecordSha256": "b" * 64,
"environment": "https://sandbox-guestops.futuresens.co.uk", "hotelLabel": "pilot-hotel-a",
"hotelCount": 1, "plannedBusinessDays": 5,
"owners": {"hotelOwner": "Hotel owner", "technicalOwner": "Technical owner", "rollbackDecisionMaker": "Technical owner"},
"startedOn": days[0], "endedOn": days[-1],
"pilotControls": {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off", "googleReviewedSending": "accepted"},
"dailyReviews": [{"date": day, "status": "pass", "reviewedBy": "Daily reviewer", "evidence": [f"restricted-{day}"]} for day in days],
"stopConditions": {condition: False for condition in pilot.STOP_CONDITIONS},
"findings": [],
"postPilotState": {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off"},
}
class PilotRunTests(unittest.TestCase):
def test_complete_five_day_record_passes(self):
pilot.validate(valid_record())
def test_exact_business_days_are_required(self):
record = valid_record();record["dailyReviews"].pop()
with self.assertRaisesRegex(ValueError, "Exactly five"):
pilot.validate(record)
record = valid_record();record["dailyReviews"][1]["date"] = "2026-10-07"
with self.assertRaisesRegex(ValueError, "each business day"):
pilot.validate(record)
def test_stop_condition_prevents_pass(self):
record = valid_record();record["stopConditions"]["duplicate-send"] = True
with self.assertRaisesRegex(ValueError, "stop condition"):
pilot.validate(record)
def test_critical_findings_cannot_be_contained(self):
record = valid_record();record["findings"] = [{"id": "security-001", "severity": "critical", "disposition": "contained", "evidence": ["restricted-finding"]}]
with self.assertRaisesRegex(ValueError, "too severe"):
pilot.validate(record)
def test_lower_severity_containment_requires_owner_expiry_and_trigger(self):
record = valid_record();record["findings"] = [{"id": "usability-001", "severity": "low", "disposition": "contained", "evidence": ["restricted-finding"]}]
with self.assertRaisesRegex(ValueError, "containment details"):
pilot.validate(record)
record["findings"][0]["containment"] = {"owner": "Finding owner", "expiresAt": "2026-10-31T12:00:00Z", "rollbackTrigger": "Stop if the issue affects guest handling."}
pilot.validate(record)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,82 @@
import hashlib
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class ReleaseRecordTests(unittest.TestCase):
def test_writes_versions_checksum_and_immutable_image_ids(self):
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "guestops-images.tar.gz"
output = Path(directory) / "release-record.json"
artifact.write_bytes(b"reviewed image archive")
subprocess.run(
[
sys.executable,
str(ROOT / "deploy" / "release_record.py"),
"--artifact",
str(artifact),
"--commit",
"a" * 40,
"--api-image",
"guestops-api:" + "a" * 40,
"--api-id",
"sha256:api",
"--worker-image",
"guestops-worker:" + "a" * 40,
"--worker-id",
"sha256:worker",
"--output",
str(output),
],
check=True,
)
record = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(record["version"], "0.2.0")
self.assertEqual(record["commit"], "a" * 40)
self.assertEqual(record["images"]["api"]["id"], "sha256:api")
self.assertEqual(
record["artifact"]["sha256"],
hashlib.sha256(artifact.read_bytes()).hexdigest(),
)
def test_rejects_abbreviated_commit(self):
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "images.tar.gz"
artifact.write_bytes(b"fixture")
result = subprocess.run(
[
sys.executable,
str(ROOT / "deploy" / "release_record.py"),
"--artifact",
str(artifact),
"--commit",
"abc123",
"--api-image",
"api:test",
"--api-id",
"sha256:api",
"--worker-image",
"worker:test",
"--worker-id",
"sha256:worker",
"--output",
str(Path(directory) / "record.json"),
],
capture_output=True,
text=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("full 40-character Git SHA", result.stderr)
if __name__ == "__main__":
unittest.main()

4
web/package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "guestops-web", "name": "guestops-web",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "guestops-web", "name": "guestops-web",
"version": "0.1.0", "version": "0.2.0",
"dependencies": { "dependencies": {
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"react": "19.2.8", "react": "19.2.8",

View File

@ -1 +1 @@
{"name":"guestops-web","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"tsc -b && vite build","check":"tsc -b"},"dependencies":{"react":"19.2.8","react-dom":"19.2.8","lucide-react":"^0.577.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","@vitejs/plugin-react":"6.1.1","typescript":"~5.9.3","vite":"8.2.2"}} {"name":"guestops-web","private":true,"version":"0.2.0","type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"tsc -b && vite build","check":"tsc -b"},"dependencies":{"react":"19.2.8","react-dom":"19.2.8","lucide-react":"^0.577.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","@vitejs/plugin-react":"6.1.1","typescript":"~5.9.3","vite":"8.2.2"}}

View File

@ -1,13 +1,16 @@
import {useEffect,useState} from 'react'; import {useEffect,useState} from 'react';
import {api,type Hotel,type Knowledge} from './api'; import {api,type Hotel,type Knowledge} from './api';
import {hotelTime} from './time';
type Rule={id:string;question:string;knowledgeId:string;knowledgeVersion:number;enabled:boolean;version:number}; type Rule={id:string;question:string;knowledgeId:string;knowledgeVersion:number;enabled:boolean;version:number};
type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimit:number}; type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimit:number};
type Decision={matches:boolean;reason:string;body:string}; type Decision={matches:boolean;reason:string;body:string};
type Evaluation={total:number;passed:number;falsePositives:number;falseNegatives:number;results:{id:string;passed:boolean;reason:string}[]};
type History={id:string;subject:string;from:string;autoReplyCheckedAt:string;autoReplyMatched:boolean;autoReplyDetail:string;delivery:string|null}; type History={id:string;subject:string;from:string;autoReplyCheckedAt:string;autoReplyMatched:boolean;autoReplyDetail:string;delivery:string|null};
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void}; type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){ export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){
const [status,setStatus]=useState<Status|null>(null),[rules,setRules]=useState<Rule[]>([]),[knowledge,setKnowledge]=useState<Knowledge[]>([]),[history,setHistory]=useState<History[]>([]); const [status,setStatus]=useState<Status|null>(null),[rules,setRules]=useState<Rule[]>([]),[knowledge,setKnowledge]=useState<Knowledge[]>([]),[history,setHistory]=useState<History[]>([]);
const [question,setQuestion]=useState(0),[answer,setAnswer]=useState(''),[enabled,setEnabled]=useState(false),[subject,setSubject]=useState('Parking question'),[body,setBody]=useState('Is parking available?'),[result,setResult]=useState<Decision|null>(null); const [question,setQuestion]=useState(0),[answer,setAnswer]=useState(''),[enabled,setEnabled]=useState(false),[subject,setSubject]=useState('Parking question'),[body,setBody]=useState('Is parking available?'),[result,setResult]=useState<Decision|null>(null);
const [evaluationText,setEvaluationText]=useState('[\n {"id":"parking-exact","subject":"Parking","body":"Is parking available?","expectedMatch":true},\n {"id":"parking-extra-request","subject":"Parking","body":"Is parking available? Also cancel my booking.","expectedMatch":false}\n]'),[evaluation,setEvaluation]=useState<Evaluation|null>(null);
async function refresh(){const [s,r,k,h]=await Promise.all([api<Status>('/auto-replies/status'),api<Rule[]>('/auto-replies/rules'),api<Knowledge[]>('/knowledge'),api<History[]>('/auto-replies/history')]);setStatus(s);setRules(r);setKnowledge(k);setHistory(h);} async function refresh(){const [s,r,k,h]=await Promise.all([api<Status>('/auto-replies/status'),api<Rule[]>('/auto-replies/rules'),api<Knowledge[]>('/knowledge'),api<History[]>('/auto-replies/history')]);setStatus(s);setRules(r);setKnowledge(k);setHistory(h);}
useEffect(()=>{run(refresh);},[hotel.id]); useEffect(()=>{run(refresh);},[hotel.id]);
const current=rules.find(r=>r.question===status?.questions[question]); const current=rules.find(r=>r.question===status?.questions[question]);
@ -18,6 +21,7 @@ export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){
<section className="settings-card"><h2>Automation mode: {hotel.autoReplyMode||'Off'}</h2><p>Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.</p><div className="form-actions"><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Off')}>Turn off</button><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Test')}>Use test mode</button><button className="button primary" disabled={busy||!owner||!status?.liveConfigured||!hotel.staffSendingEnabled} onClick={()=>mode('Live')}>Enable live replies</button></div><p className="small muted">Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.</p><p className="small muted">Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.</p>{status?.preview&&<p className="staff-note">Sample workspace: the question tester works here. Live sending is disabled.</p>}</section> <section className="settings-card"><h2>Automation mode: {hotel.autoReplyMode||'Off'}</h2><p>Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.</p><div className="form-actions"><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Off')}>Turn off</button><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Test')}>Use test mode</button><button className="button primary" disabled={busy||!owner||!status?.liveConfigured||!hotel.staffSendingEnabled} onClick={()=>mode('Live')}>Enable live replies</button></div><p className="small muted">Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.</p><p className="small muted">Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.</p>{status?.preview&&<p className="staff-note">Sample workspace: the question tester works here. Live sending is disabled.</p>}</section>
<div className="pms-columns"><section className="settings-card"><h2>Review a FAQ rule</h2><form onSubmit={save}><fieldset disabled={busy||!owner}><label>Complete guest question<select value={question} onChange={e=>setQuestion(Number(e.target.value))}>{status?.questions.map((q,i)=><option value={i} key={q}>{q}</option>)}</select></label><label>Approved hotel answer<select value={answer} required onChange={e=>setAnswer(e.target.value)}><option value="">Choose an answer</option>{knowledge.filter(k=>k.approved).map(k=><option value={k.id} key={k.id}>{k.title}</option>)}</select></label>{answer&&<p className="staff-note">{knowledge.find(k=>k.id===answer)?.answer}</p>}<label className="checkbox-label"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>Enable this exact question and reviewed answer</label><button className="button secondary">Save reviewed rule</button></fieldset></form><p className="small muted">The approved answer is sent exactly as saved, with the hotel signature. Editing the answer pauses matching until this rule is reviewed and saved again.</p></section> <div className="pms-columns"><section className="settings-card"><h2>Review a FAQ rule</h2><form onSubmit={save}><fieldset disabled={busy||!owner}><label>Complete guest question<select value={question} onChange={e=>setQuestion(Number(e.target.value))}>{status?.questions.map((q,i)=><option value={i} key={q}>{q}</option>)}</select></label><label>Approved hotel answer<select value={answer} required onChange={e=>setAnswer(e.target.value)}><option value="">Choose an answer</option>{knowledge.filter(k=>k.approved).map(k=><option value={k.id} key={k.id}>{k.title}</option>)}</select></label>{answer&&<p className="staff-note">{knowledge.find(k=>k.id===answer)?.answer}</p>}<label className="checkbox-label"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>Enable this exact question and reviewed answer</label><button className="button secondary">Save reviewed rule</button></fieldset></form><p className="small muted">The approved answer is sent exactly as saved, with the hotel signature. Editing the answer pauses matching until this rule is reviewed and saved again.</p></section>
<section className="settings-card"><h2>Try a question</h2><form onSubmit={e=>{e.preventDefault();run(async()=>setResult(await api<Decision>('/auto-replies/test','POST',{subject,body})));}}><fieldset disabled={busy||!owner}><label>Subject<input value={subject} maxLength={200} onChange={e=>setSubject(e.target.value)}/></label><label>Complete message<textarea rows={4} value={body} maxLength={2000} required onChange={e=>setBody(e.target.value)}/></label><button className="button secondary">Check match without sending</button></fieldset></form>{result&&<div role="status" className="staff-note"><strong>{result.matches?'Content matches a reviewed rule':'Keep with staff'}</strong><p>{result.reason}</p>{result.body&&<p>{result.body}</p>}</div>}<p className="small muted">This tester checks content only. Real messages must also pass sender, recipient, age, thread and delivery-limit checks.</p></section></div> <section className="settings-card"><h2>Try a question</h2><form onSubmit={e=>{e.preventDefault();run(async()=>setResult(await api<Decision>('/auto-replies/test','POST',{subject,body})));}}><fieldset disabled={busy||!owner}><label>Subject<input value={subject} maxLength={200} onChange={e=>setSubject(e.target.value)}/></label><label>Complete message<textarea rows={4} value={body} maxLength={2000} required onChange={e=>setBody(e.target.value)}/></label><button className="button secondary">Check match without sending</button></fieldset></form>{result&&<div role="status" className="staff-note"><strong>{result.matches?'Content matches a reviewed rule':'Keep with staff'}</strong><p>{result.reason}</p>{result.body&&<p>{result.body}</p>}</div>}<p className="small muted">This tester checks content only. Real messages must also pass sender, recipient, age, thread and delivery-limit checks.</p></section></div>
<section className="settings-card"><h2>Recent incoming-message results</h2>{history.length===0?<p>No incoming messages have been evaluated yet. Enable test mode after connecting your mailbox.</p>:<div className="pms-history">{history.map(h=><div className="pms-history-item" key={h.id}><strong>{h.subject}</strong><span>{h.autoReplyDetail}</span><span>{h.delivery?`Delivery: ${h.delivery} · `:''}{new Date(h.autoReplyCheckedAt).toLocaleString()}</span></div>)}</div>}</section> <section className="settings-card"><h2>Recent incoming-message results</h2>{history.length===0?<p>No incoming messages have been evaluated yet. Enable test mode after connecting your mailbox.</p>:<div className="pms-history">{history.map(h=><div className="pms-history-item" key={h.id}><strong>{h.subject}</strong><span>{h.autoReplyDetail}</span><span>{h.delivery?`Delivery: ${h.delivery} · `:''}{hotelTime(h.autoReplyCheckedAt,hotel.timezone)}</span></div>)}</div>}</section>
<section className="settings-card"><h2>Batch safety evaluation</h2><p>Evaluate up to 100 synthetic cases without saving messages or sending email. Include expected matches and expected exclusions.</p><label>Evaluation cases (JSON)<textarea rows={9} value={evaluationText} onChange={e=>setEvaluationText(e.target.value)}/></label><button className="button secondary" disabled={busy||!owner} onClick={()=>run(async()=>setEvaluation(await api<Evaluation>('/auto-replies/evaluate','POST',{cases:JSON.parse(evaluationText)})))}>Run no-send evaluation</button>{evaluation&&<div className="staff-note" role="status"><strong>{evaluation.passed} of {evaluation.total} cases passed</strong><p>False positives: {evaluation.falsePositives} · False negatives: {evaluation.falseNegatives}</p>{evaluation.results.filter(r=>!r.passed).map(r=><p key={r.id}><strong>{r.id}:</strong> {r.reason}</p>)}</div>}<p className="small muted">Use synthetic content only. A passing content evaluation does not test Gmail headers, quotas, threading or delivery.</p></section>
</div>; </div>;
} }

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Mail, RefreshCw, ShieldCheck } from 'lucide-react'; import { Mail, RefreshCw, ShieldCheck } from 'lucide-react';
import { api, type Mailboxes } from './api'; import { api, type Mailboxes } from './api';
import {hotelTime} from './time';
type Run=(action:()=>Promise<void>)=>Promise<void>; type Run=(action:()=>Promise<void>)=>Promise<void>;
const when=(value:string|null)=>value?new Date(value).toLocaleString():'Not yet';
export function MailboxPanel({data,owner,preview,busy,run,onChange}:{data:Mailboxes;owner:boolean;preview:boolean;busy:boolean;run:Run;onChange:(value:Mailboxes)=>void}){ export function MailboxPanel({data,owner,preview,busy,run,onChange}:{data:Mailboxes;owner:boolean;preview:boolean;busy:boolean;run:Run;onChange:(value:Mailboxes)=>void}){
const when=(value:string|null)=>value?hotelTime(value,data.timeZone||'UTC'):'Not yet';
const [pollError,setPollError]=useState(''); const [pollError,setPollError]=useState('');
async function refresh(){onChange(await api<Mailboxes>('/mailboxes'));setPollError('');} async function refresh(){onChange(await api<Mailboxes>('/mailboxes'));setPollError('');}
useEffect(()=>{let active=true;const timer=setInterval(()=>{if(document.hidden)return;api<Mailboxes>('/mailboxes').then(value=>{if(active){onChange(value);setPollError('');}}).catch(()=>{if(active)setPollError('Status could not be refreshed. Use Refresh status to check again.');});},30000);return()=>{active=false;clearInterval(timer);};},[onChange]); useEffect(()=>{let active=true;const timer=setInterval(()=>{if(document.hidden)return;api<Mailboxes>('/mailboxes').then(value=>{if(active){onChange(value);setPollError('');}}).catch(()=>{if(active)setPollError('Status could not be refreshed. Use Refresh status to check again.');});},30000);return()=>{active=false;clearInterval(timer);};},[onChange]);

View File

@ -1,10 +1,29 @@
import { useEffect, useState } from 'react'; import {useEffect,useState} from 'react';
import { api } from './api'; import {api} from './api';
type Health={checkedAt:string;preview:boolean;database:string;worker:{state:string;lastSeenAt:string|null};mailboxes:{total:number;connected:number;attention:number};replies:{sampleSize:number;sampleLimit:number;pending:number;uncertain:number;rejected:number}}; import {hotelTime} from './time';
type Health={
checkedAt:string;timeZone:string;preview:boolean;database:string;
worker:{state:string;lastSeenAt:string|null};
mailboxes:{total:number;connected:number;attention:number};
replies:{sampleSize:number;sampleLimit:number;pending:number;uncertain:number;rejected:number};
};
export function OperationsPage({owner,go}:{owner:boolean;go:(path:string)=>void}){ export function OperationsPage({owner,go}:{owner:boolean;go:(path:string)=>void}){
const [data,setData]=useState<Health|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false); const [data,setData]=useState<Health|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false);
async function refresh(){setBusy(true);setError('');try{setData(await api<Health>('/operations'));}catch(e){setError(e instanceof Error?e.message:'Unable to check workspace health.');}finally{setBusy(false);}} async function refresh(){setBusy(true);setError('');try{setData(await api<Health>('/operations'));}catch(e){setError(e instanceof Error?e.message:'Unable to check workspace health.');}finally{setBusy(false);}}
useEffect(()=>{if(owner)void refresh();},[owner]); useEffect(()=>{if(owner)void refresh();},[owner]);
if(!owner)return <div className="page"><h1>Workspace health</h1><p>Your hotel owner can review operational health.</p></div>; if(!owner)return <div className="page"><h1>Workspace health</h1><p>Your hotel owner can review operational health.</p></div>;
return <div className="page settings-page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Keep your workspace running</span><h1>Workspace health</h1><p>Spot connection and delivery issues before they affect your team.</p></div><button className="button secondary" disabled={busy} onClick={refresh}>{busy?'Checking…':'Refresh health'}</button></div>{error&&<div className="alert" role="alert">{error} The information below may be out of date.</div>}{data&&<><p className="small muted">Checked {new Date(data.checkedAt).toLocaleString()}{data.preview?' · Sample workspace':''}</p><section className="settings-card"><h2>Application and worker</h2><div className="mailbox-health"><div><span>Database connection</span><strong>{data.preview?'Temporary preview storage':data.database}</strong></div><div><span>Background worker</span><strong>{data.worker.state==='Reporting'?'Reporting normally':data.worker.state==='Preview'?'Unavailable in preview':data.worker.state==='Stale'?'Heartbeat overdue':'No heartbeat received'}</strong></div><div><span>Last worker heartbeat</span><strong>{data.worker.lastSeenAt?new Date(data.worker.lastSeenAt).toLocaleString():'Not yet'}</strong></div></div>{['Stale','NotSeen'].includes(data.worker.state)&&<p className="mailbox-explanation">Ask your server administrator to check the worker container and its database connection. Imports and queued replies may be delayed.</p>}<p className="small muted">A heartbeat confirms the worker process can reach storage. It does not prove that Google, payment or PMS requests are succeeding.</p></section><section className="settings-card"><h2>Mailbox connections</h2><div className="mailbox-health"><div><span>Total</span><strong>{data.mailboxes.total}</strong></div><div><span>Connected</span><strong>{data.mailboxes.connected}</strong></div><div><span>Need attention</span><strong>{data.mailboxes.attention}</strong></div></div><button className="button secondary" onClick={()=>go('/settings')}>Review mailbox status</button></section><section className="settings-card"><h2>Reply delivery</h2><div className="mailbox-health"><div><span>Queued or submitting</span><strong>{data.replies.pending}</strong></div><div><span>Need verification</span><strong>{data.replies.uncertain}</strong></div><div><span>Stopped before sending</span><strong>{data.replies.rejected}</strong></div></div><p className="small muted">Based on {data.replies.sampleSize} recent conversations, up to {data.replies.sampleLimit}. Older deliveries may exist. Verify uncertain results in Gmail before taking further action.</p><div className="form-actions"><button className="button secondary" onClick={()=>go('/inbox')}>Review the inbox</button></div></section><section className="settings-card"><h2>Backups and recovery</h2><p className="muted">Your server administrator runs encrypted backups and isolated restore drills. Ask them to confirm the latest off-server backup and successful restore test.</p><p className="small muted">This page does not claim a backup exists or that the server is ready for production. Provider acceptance and recovery checks are separate.</p></section></>}</div>; const when=(value:string|null)=>value?hotelTime(value,data?.timeZone||'UTC'):'Not yet';
return <div className="page settings-page">
<div className="heading-row"><div className="page-heading"><span className="eyebrow">Keep your workspace running</span><h1>Workspace health</h1><p>Spot connection and delivery issues before they affect your team.</p></div><button className="button secondary" disabled={busy} onClick={refresh}>{busy?'Checking…':'Refresh health'}</button></div>
{error&&<div className="alert" role="alert">{error} The information below may be out of date.</div>}
{data&&<>
<p className="small muted">Checked {when(data.checkedAt)}{data.preview?' · Sample workspace':''}</p>
<section className="settings-card"><h2>Application and worker</h2><div className="mailbox-health"><div><span>Database connection</span><strong>{data.preview?'Temporary preview storage':data.database}</strong></div><div><span>Background worker</span><strong>{data.worker.state==='Reporting'?'Reporting normally':data.worker.state==='Preview'?'Unavailable in preview':data.worker.state==='Stale'?'Heartbeat overdue':'No heartbeat received'}</strong></div><div><span>Last worker heartbeat</span><strong>{when(data.worker.lastSeenAt)}</strong></div></div>{['Stale','NotSeen'].includes(data.worker.state)&&<p className="mailbox-explanation">Ask your server administrator to check the worker container and its database connection. Imports and queued replies may be delayed.</p>}<p className="small muted">A heartbeat confirms the worker process can reach storage. It does not prove that Google, payment or PMS requests are succeeding.</p></section>
<section className="settings-card"><h2>Mailbox connections</h2><div className="mailbox-health"><div><span>Total</span><strong>{data.mailboxes.total}</strong></div><div><span>Connected</span><strong>{data.mailboxes.connected}</strong></div><div><span>Need attention</span><strong>{data.mailboxes.attention}</strong></div></div><button className="button secondary" onClick={()=>go('/settings')}>Review mailbox status</button></section>
<section className="settings-card"><h2>Reply delivery</h2><div className="mailbox-health"><div><span>Queued or submitting</span><strong>{data.replies.pending}</strong></div><div><span>Need verification</span><strong>{data.replies.uncertain}</strong></div><div><span>Stopped before sending</span><strong>{data.replies.rejected}</strong></div></div><p className="small muted">Based on {data.replies.sampleSize} recent conversations, up to {data.replies.sampleLimit}. Older deliveries may exist. Verify uncertain results in Gmail before taking further action.</p><div className="form-actions"><button className="button secondary" onClick={()=>go('/inbox')}>Review the inbox</button></div></section>
<section className="settings-card"><h2>Backups and recovery</h2><p className="muted">Your server administrator runs encrypted backups and isolated restore drills. Ask them to confirm the latest off-server backup and successful restore test.</p><p className="small muted">This page does not claim a backup exists or that the server is ready for production. Provider acceptance and recovery checks are separate.</p></section>
</>}
</div>;
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, type Hotel } from './api'; 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 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 Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean};
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void}; type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
@ -19,7 +20,7 @@ export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
<section className="settings-card"><h2>Payment connection</h2><p>{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.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section> <section className="settings-card"><h2>Payment connection</h2><p>{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.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section>
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section> <div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
<section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div> <section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div>
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?new Date(current.checkedAt).toLocaleString():'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p> {current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?hotelTime(current.checkedAt,hotel.timezone):'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
{current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>} {current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>}
<p className="small muted">A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.</p> <p className="small muted">A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.</p>
</section>} </section>}

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api, type Hotel } from './api'; 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 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 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}; type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean};
@ -21,7 +22,7 @@ export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
<section className="settings-card"><h2>OHIP connection</h2><p>{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.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.pmsUpdatesEnabled||false} disabled={busy||!owner||(!connection?.writesConfigured&&!hotel.pmsUpdatesEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable staff-approved PMS updates for this hotel? Only enable this after the configured OHIP sandbox has passed acceptance checks.'))return;onHotel(await api<Hotel>('/pms/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved PMS updates</label><p className="small muted">Lookup is available separately. Every change needs review; automatic PMS updates are off.</p></section> <section className="settings-card"><h2>OHIP connection</h2><p>{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.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.pmsUpdatesEnabled||false} disabled={busy||!owner||(!connection?.writesConfigured&&!hotel.pmsUpdatesEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable staff-approved PMS updates for this hotel? Only enable this after the configured OHIP sandbox has passed acceptance checks.'))return;onHotel(await api<Hotel>('/pms/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved PMS updates</label><p className="small muted">Lookup is available separately. Every change needs review; automatic PMS updates are off.</p></section>
<div className="pms-columns"><section className="settings-card"><h2>Find a reservation</h2><form onSubmit={lookup}><label>Exact confirmation number<input value={confirmation} maxLength={80} pattern="[a-zA-Z0-9-]+" required onChange={e=>setConfirmation(e.target.value)} placeholder="For example, 12345678"/></label><button className="button primary" disabled={busy||!connection?.configured}>Look up reservation</button></form> <div className="pms-columns"><section className="settings-card"><h2>Find a reservation</h2><form onSubmit={lookup}><label>Exact confirmation number<input value={confirmation} maxLength={80} pattern="[a-zA-Z0-9-]+" required onChange={e=>setConfirmation(e.target.value)} placeholder="For example, 12345678"/></label><button className="button primary" disabled={busy||!connection?.configured}>Look up reservation</button></form>
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!owner}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>} {snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!owner}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {new Date(c.updatedAt).toLocaleString()}</span></button>)}</div></section></div> </section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {hotelTime(c.updatedAt,hotel.timezone)}</span></button>)}</div></section></div>
{current&&<section className="settings-card pms-review" aria-label="PMS change review"><h2>{current.state==='Review'?'Review this PMS change':'PMS change status'}</h2><Reservation value={current.before}/><div className="staff-note">{current.kind==='StayDates'?`Requested stay: ${current.arrival} to ${current.departure}`:`Add internal note: ${current.note}`}</div><p role="status"><strong>{current.state==='NeedsReview'?'Needs verification':current.state}</strong> — {current.detail}</p>{current.after&&<><h3>Latest observed PMS state</h3><Reservation value={current.after}/></>}{current.state==='Review'&&<>{current.kind==='StayDates'&&<label className="checkbox-label"><input type="checkbox" checked={checked} onChange={e=>setChecked(e.target.checked)}/>I checked availability, rate consequences and guest agreement in the PMS. GuestOps does not quote or guarantee a new price here.</label>}<div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||!hotel.pmsUpdatesEnabled||!connection?.writesConfigured||(current.kind==='StayDates'&&!checked)||new Date(current.expiresAt)<new Date()} onClick={()=>action('apply')}>Approve and apply to PMS</button></div></>}{(current.state==='NeedsReview'||current.state==='Applying')&&<><button className="button secondary" disabled={busy||!owner||(current.state==='Applying'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('verify')}>Verify current PMS state</button><p className="small muted">An interrupted update can be checked after five minutes. Verification only reads the PMS; it never repeats the update.</p></>}<p className="small muted">Operation reference: {current.id}</p></section>} {current&&<section className="settings-card pms-review" aria-label="PMS change review"><h2>{current.state==='Review'?'Review this PMS change':'PMS change status'}</h2><Reservation value={current.before}/><div className="staff-note">{current.kind==='StayDates'?`Requested stay: ${current.arrival} to ${current.departure}`:`Add internal note: ${current.note}`}</div><p role="status"><strong>{current.state==='NeedsReview'?'Needs verification':current.state}</strong> — {current.detail}</p>{current.after&&<><h3>Latest observed PMS state</h3><Reservation value={current.after}/></>}{current.state==='Review'&&<>{current.kind==='StayDates'&&<label className="checkbox-label"><input type="checkbox" checked={checked} onChange={e=>setChecked(e.target.checked)}/>I checked availability, rate consequences and guest agreement in the PMS. GuestOps does not quote or guarantee a new price here.</label>}<div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||!hotel.pmsUpdatesEnabled||!connection?.writesConfigured||(current.kind==='StayDates'&&!checked)||new Date(current.expiresAt)<new Date()} onClick={()=>action('apply')}>Approve and apply to PMS</button></div></>}{(current.state==='NeedsReview'||current.state==='Applying')&&<><button className="button secondary" disabled={busy||!owner||(current.state==='Applying'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('verify')}>Verify current PMS state</button><p className="small muted">An interrupted update can be checked after five minutes. Verification only reads the PMS; it never repeats the update.</p></>}<p className="small muted">Operation reference: {current.id}</p></section>}
</div>; </div>;
} }

View File

@ -1,17 +1,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from './api'; import { api } from './api';
import { hotelTime } from './time';
type Member={id:string;name:string;email:string;role:string;active:boolean;pending:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null}; type Member={id:string;name:string;email:string;role:string;active:boolean;pending:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
type Link={userId:string;link:string;expiresAt:string}; type Link={userId:string;link:string;expiresAt:string};
export function TeamPage({owner}:{owner:boolean}) { export function TeamPage({owner,timeZone}:{owner:boolean;timeZone:string}) {
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false); const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
async function refresh(){setMembers(await api<Member[]>('/team'));} async function refresh(){setMembers(await api<Member[]>('/team'));}
useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]); useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]);
async function act(path:string,body:unknown){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,'POST',body);if(result?.link)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}} async function act(path:string,body:unknown){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,'POST',body);if(result?.link)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>; if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>;
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>} return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>}
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {new Date(link.expiresAt).toLocaleString()}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>} {link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {hotelTime(link.expiresAt,timeZone)}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Staff can work on guest conversations. Owners manage hotel settings, integrations and approvals.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><div className="form-actions"><button className="button primary">Create invitation link</button></div></fieldset></form></section> <section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Staff can work on guest conversations. Owners manage hotel settings, integrations and approvals.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><div className="form-actions"><button className="button primary">Create invitation link</button></div></fieldset></form></section>
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {new Date(m.linkExpiresAt!).toLocaleString()}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section> <section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
</div>; </div>;
} }
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]}; type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};

View File

@ -2,9 +2,10 @@ export type User = { id: string; name: string; role: string; hotelId: string };
export type Session = { preview: boolean; csrfToken: string; user: User | null }; export type Session = { preview: boolean; csrfToken: string; user: User | null };
export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; autoReplyMode: string; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean }; export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; autoReplyMode: string; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean };
export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null }; export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null };
export type ConversationPage = { items: Conversation[]; nextCursor: string | null };
export type Knowledge = { id: string; title: string; category: string; answer: string; keywords: string; approved: boolean; version: number }; export type Knowledge = { id: string; title: string; category: string; answer: string; keywords: string; approved: boolean; version: number };
export type Activity = { id: string; at: string; userName: string; action: string }; export type Activity = { id: string; at: string; userName: string; action: string };
export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; items: { id: string; email: string; status: string; version: number; lastSyncAt: string | null; lastAttemptAt: string | null; nextAttemptAt: string | null; failureCount: number; syncErrorCode: string; catchingUp: boolean; syncError: string; canSend: boolean }[] }; export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; timeZone?: string; items: { id: string; email: string; status: string; version: number; lastSyncAt: string | null; lastAttemptAt: string | null; nextAttemptAt: string | null; failureCount: number; syncErrorCode: string; catchingUp: boolean; syncError: string; canSend: boolean }[] };
let csrf = ''; let csrf = '';
export async function api<T>(path: string, method = 'GET', body?: unknown): Promise<T> { export async function api<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
const response = await fetch('/api' + path, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }, body: body === undefined ? undefined : JSON.stringify(body) }); const response = await fetch('/api' + path, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }, body: body === undefined ? undefined : JSON.stringify(body) });

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react'; import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react';
import { api, session, type Session, type Hotel, type Conversation, type Knowledge, type Activity, type Mailboxes } from './api'; import { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
import './style.css'; import './style.css';
import { OperationsPage } from './OperationsPage'; import { OperationsPage } from './OperationsPage';
import { MailboxPanel } from './MailboxPanel'; import { MailboxPanel } from './MailboxPanel';
@ -14,18 +14,22 @@ import { ReplyActions, ReplyControls } from './ReplyActions';
const labels: Record<string,string> = { NeedsAttention: 'Needs attention', DraftReady: 'Draft ready', Completed: 'Completed' }; const labels: Record<string,string> = { NeedsAttention: 'Needs attention', DraftReady: 'Draft ready', Completed: 'Completed' };
const initials = (name: string) => name.replace(/<.*>/, '').trim().split(' ').filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase(); const initials = (name: string) => name.replace(/<.*>/, '').trim().split(' ').filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase();
const sender = (name: string) => name.replace(/<.*>/, '').replaceAll('"', '').trim(); const sender = (name: string) => name.replace(/<.*>/, '').replaceAll('"', '').trim();
const date = (value: string) => new Date(value).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); const date = (value: string,timeZone: string) => new Date(value).toLocaleString(undefined, { timeZone, day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
function App() { function App() {
const [auth,setAuth] = useState<Session|null>(null), [error,setError] = useState(''), [notice,setNotice] = useState(''); const [auth,setAuth] = useState<Session|null>(null), [error,setError] = useState(''), [notice,setNotice] = useState('');
const [hotel,setHotel] = useState<Hotel|null>(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false); const [hotel,setHotel] = useState<Hotel|null>(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false);
const pageRef=useRef(page);
const [conversations,setConversations] = useState<Conversation[]>([]), [knowledge,setKnowledge] = useState<Knowledge[]>([]), [activity,setActivity] = useState<Activity[]>([]), [mailboxes,setMailboxes] = useState<Mailboxes>({ configured:false,items:[] }); const [conversations,setConversations] = useState<Conversation[]>([]), [knowledge,setKnowledge] = useState<Knowledge[]>([]), [activity,setActivity] = useState<Activity[]>([]), [mailboxes,setMailboxes] = useState<Mailboxes>({ configured:false,items:[] });
const [conversationCursor,setConversationCursor]=useState<string|null>(null);
const [loaded,setLoaded] = useState(false); const [loaded,setLoaded] = useState(false);
async function refresh() { async function refresh() {
const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<Conversation[]>('/conversations'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]); const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<ConversationPage>('/conversations/page'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]);
setHotel(h);setConversations(c);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true); setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
} }
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>setPage(location.pathname);window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]); async function moreConversations(){if(!conversationCursor)return;const page=await api<ConversationPage>('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);}
useEffect(()=>{pageRef.current=page;},[page]);
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>{if(!window.dispatchEvent(new Event('workspace-navigate',{cancelable:true}))){history.pushState({},'',pageRef.current);return;}setPage(location.pathname==='/'?'/inbox':location.pathname);};window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]); useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]);
useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]); useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} } async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} }
@ -49,7 +53,7 @@ function App() {
<main className="main"> <main className="main">
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header> <header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>} {errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>} {!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
</main> </main>
</div>; </div>;
} }
@ -60,7 +64,7 @@ function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>Welcome back</h1><p>Sign in to take care of your guests.</p>{error}<form onSubmit={e=>{e.preventDefault();onLogin(email,password);}}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><p className="small muted">Need access or help signing in? Contact your hotel administrator.</p>{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</div></section></div>; return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>Welcome back</h1><p>Sign in to take care of your guests.</p>{error}<form onSubmit={e=>{e.preventDefault();onLogin(email,password);}}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><p className="small muted">Need access or help signing in? Contact your hotel administrator.</p>{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</div></section></div>;
} }
type Run=(a:()=>Promise<void>)=>Promise<void>; type Run=(a:()=>Promise<void>)=>Promise<void>;
function InboxPage({hotel,mailboxes,conversations,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){ function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){
const [filter,setFilter]=useState('All'),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false); const [filter,setFilter]=useState('All'),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
const filtered=conversations.filter(c=>(filter==='All'||c.status===filter)&&(c.subject+' '+c.from+' '+c.body).toLowerCase().includes(search.toLowerCase())); const filtered=conversations.filter(c=>(filter==='All'||c.status===filter)&&(c.subject+' '+c.from+' '+c.body).toLowerCase().includes(search.toLowerCase()));
const current=filtered.find(c=>c.id===selected)||filtered[0]; const current=filtered.find(c=>c.id===selected)||filtered[0];
@ -71,9 +75,9 @@ function InboxPage({hotel,mailboxes,conversations,knowledge,busy,run,onUpdate,no
async function save() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/draft`,'PUT',{draft,version:current.version}));notify('Draft saved. Nothing has been sent.');});} async function save() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/draft`,'PUT',{draft,version:current.version}));notify('Draft saved. Nothing has been sent.');});}
async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});} async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});}
return <div className="inbox-page"><div className="inbox-heading"><PageHeading eyebrow="A warm welcome starts here" title="Your guest inbox" text={needs?`${needs} conversations need your attention. Let's make their day.`:'A little space to focus on your guests.'}/><div className="mini-stats"><div><strong>{needs}</strong><span>Need attention</span></div><div><strong>{ready}</strong><span>Drafts ready</span></div></div></div> return <div className="inbox-page"><div className="inbox-heading"><PageHeading eyebrow="A warm welcome starts here" title="Your guest inbox" text={needs?`${needs} conversations need your attention. Let's make their day.`:'A little space to focus on your guests.'}/><div className="mini-stats"><div><strong>{needs}</strong><span>Need attention</span></div><div><strong>{ready}</strong><span>Drafts ready</span></div></div></div>
<div className="inbox-toolbar"><div className="tabs" role="group" aria-label="Filter conversations">{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=><button key={key} className={filter===key?'tab selected':'tab'} onClick={()=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setFilter(key);}}>{label}{key==='NeedsAttention'&&needs>0&&<span>{needs}</span>}</button>)}</div><label className="search"><Search size={17}/><input aria-label="Search conversations" placeholder="Search messages…" value={search} onChange={e=>setSearch(e.target.value)}/></label></div> <div className="inbox-toolbar"><div className="tabs" role="group" aria-label="Filter conversations">{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=><button key={key} className={filter===key?'tab selected':'tab'} onClick={()=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setFilter(key);}}>{label}{key==='NeedsAttention'&&needs>0&&<span>{needs}</span>}</button>)}</div><label className="search"><Search size={17}/><input aria-label="Search conversations" placeholder="Search loaded messages…" value={search} onChange={e=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setSearch(e.target.value);}}/></label></div>
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section> {!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} loaded conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{timeZone:hotel.timezone,hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{hasMore&&<button className="button secondary wide" disabled={busy} onClick={loadMore}>Load 50 older conversations</button>}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&&current.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>; <section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&&current.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt,hotel.timezone)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
} }
function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge[];canEdit:boolean;busy:boolean;run:Run;onUpdate:(k:Knowledge)=>void;notify:(s:string)=>void}){ function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge[];canEdit:boolean;busy:boolean;run:Run;onUpdate:(k:Knowledge)=>void;notify:(s:string)=>void}){
const [edit,setEdit]=useState<Knowledge|null>(null),[search,setSearch]=useState(''); const [edit,setEdit]=useState<Knowledge|null>(null),[search,setSearch]=useState('');

3
web/src/time.ts Normal file
View File

@ -0,0 +1,3 @@
export function hotelTime(value:string,timeZone:string,options?:Intl.DateTimeFormatOptions){
return new Intl.DateTimeFormat(undefined,{timeZone,...options}).format(new Date(value));
}