Add reviewed OHIP reservation updates and durable reconciliation
This commit is contained in:
parent
b1548ed6e6
commit
5bd71175c0
@ -8,3 +8,4 @@
|
||||
**/keys
|
||||
tests
|
||||
*.tar*
|
||||
**/pms.local.json
|
||||
|
||||
@ -8,6 +8,9 @@ GOOGLE_ENABLE_SENDING=false
|
||||
# Only API service needs the AI key. AI remains off per hotel until owner opts in.
|
||||
AI_API_KEY=
|
||||
AI_MODEL=
|
||||
# Optional private host-side JSON file binding internal hotel IDs to OHIP credentials.
|
||||
# Default example has no connections. Never commit the real configuration.
|
||||
PMS_CONFIG_FILE_HOST=./deploy/pms.example.json
|
||||
# CI produces image archives. Set these to the loaded, reviewed commit tags.
|
||||
GUESTOPS_API_IMAGE=guestops-api:local
|
||||
GUESTOPS_WORKER_IMAGE=guestops-worker:local
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,3 +12,4 @@
|
||||
*.tar
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
**/pms.local.json
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# GuestOps Web
|
||||
|
||||
A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation and staff-approved Gmail sending. 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 and reviewed OHIP reservation updates. It is not yet a production-complete replacement.**
|
||||
|
||||
## Implemented so far
|
||||
|
||||
@ -10,12 +10,12 @@ A Linux-hosted hotel email workspace, developed separately from the Windows Gues
|
||||
- Optional OpenAI drafts based on approved hotel answers, with source references and staff escalation.
|
||||
- Staff-approved Gmail replies with immutable approval snapshots, duplicate prevention and uncertain-delivery verification. Both AI and sending are off by default per hotel.
|
||||
- Google OAuth connection and a separate Gmail import/delivery worker. The first import covers seven days of inbox messages; paginated checkpoints and duplicate suppression survive restarts.
|
||||
- Windows-independent booking model, validation, email cleaning, JSON extraction and secret redaction migrated from the hardened desktop code. Live PMS writes have not been ported or enabled.
|
||||
- Windows-independent booking model, validation, email cleaning, JSON extraction and secret redaction migrated from the hardened desktop code. OHIP exact reservation lookup, internal notes and owner-approved stay-date changes are implemented with durable review and read-only reconciliation; writes are off by default.
|
||||
- Docker image builds, private MongoDB configuration and an Nginx HTTPS example for the Debian sandbox.
|
||||
|
||||
## Explicit limits
|
||||
|
||||
Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Automatic sending, PMS/payment workflows, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator.
|
||||
Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Automatic sending, wider PMS workflows, payment links, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator.
|
||||
|
||||
The Google integration needs OAuth credentials and a sandbox mailbox before its live behaviour can be accepted. Automated tests do not access Gmail or a hotel system. An integration being implemented is not a claim of Google verification or production readiness.
|
||||
|
||||
@ -58,4 +58,5 @@ cd web && npm ci && npm run build
|
||||
|
||||
Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. CI runs both integrations and builds both Linux images.
|
||||
|
||||
See [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md).
|
||||
See [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md).
|
||||
|
||||
|
||||
@ -25,7 +25,8 @@ services:
|
||||
Proxy__KnownAddress: ${GUESTOPS_GATEWAY:-172.30.87.1}
|
||||
Ai__ApiKey: ${AI_API_KEY:-}
|
||||
Ai__Model: ${AI_MODEL:-}
|
||||
volumes: ["app-keys:/var/lib/guestops/keys"]
|
||||
Pms__ConfigFile: /run/guestops/pms.json
|
||||
volumes: ["app-keys:/var/lib/guestops/keys", "${PMS_CONFIG_FILE_HOST:-./deploy/pms.example.json}:/run/guestops/pms.json:ro"]
|
||||
depends_on:
|
||||
mongo: { condition: service_healthy }
|
||||
logging: *logging
|
||||
|
||||
1
deploy/pms.example.json
Normal file
1
deploy/pms.example.json
Normal file
@ -0,0 +1 @@
|
||||
{"Pms":{"Hotels":{}}}
|
||||
@ -20,16 +20,21 @@ Gmail permissions are read-only. The worker fetches plain-text bodies and skips
|
||||
|
||||
Implemented optional OpenAI draft generation, validated hotel answer references, per-hotel owner controls, staff-approved Gmail sending, immutable MongoDB approval snapshots, worker claims and uncertain-delivery verification. Live provider acceptance remains pending. See [reply setup and recovery](replies.md). Automatic sending remains disabled. The read-only description above describes milestone 1 defaults; sending now requires explicit additional configuration and consent.
|
||||
|
||||
## Milestone 3: reviewed OHIP reservation updates
|
||||
|
||||
Implemented exact confirmation lookup, internal notes and owner-approved stay-date changes, with tenant-specific server credentials, MongoDB proposals, duplicate approval prevention, stale-booking checks and read-only recovery of uncertain results. Live OHIP sandbox acceptance is pending; writes remain off by default. See [PMS setup and limitations](pms.md).
|
||||
|
||||
## Remaining milestones
|
||||
|
||||
1. Test Google connection with a dedicated test mailbox; add provider fixture tests, disconnect/revocation, refresh failure recovery and full thread aggregation.
|
||||
2. Add staff invitations, account recovery, granular roles, user preferences and an onboarding wizard.
|
||||
3. Run a representative live AI draft evaluation, improve retrieval and evidence presentation, and approve the selected provider data-processing arrangements.
|
||||
4. Extend the implemented durable reply queue with operator recovery tooling and guarded FAQ auto-replies after live acceptance. Preserve the rule that uncertain sends are never blindly replayed.
|
||||
5. Port supported PMS/payment adapters with vendor sandbox contract tests and reconciliation UI. Do not enable these by merely copying desktop settings or toggling a feature flag.
|
||||
5. Validate the OHIP adapter against the property sandbox, extend supported PMS operations and implement payment links with provider sandbox tests and reconciliation. Do not enable these by merely copying desktop settings or toggling a feature flag.
|
||||
|
||||
Windows DPAPI settings must not be copied to Linux as usable credentials. Re-enter provider credentials through the server setup and reauthorize each mailbox. No existing hotel data has been transferred.
|
||||
|
||||
## Capacity and operations
|
||||
|
||||
Target supplied by the owner: Debian 12, 4 CPU cores, 7.6 GiB RAM, 18 GiB free disk; MongoDB on the same machine. Compose includes conservative starting memory limits and capped logs, not a capacity guarantee. Keep database/key backups off-server and do not import entire mailboxes by default. Establish retention and restore testing before real guest data is used.
|
||||
|
||||
|
||||
54
docs/pms.md
Normal file
54
docs/pms.md
Normal file
@ -0,0 +1,54 @@
|
||||
# OHIP reservations and approved changes
|
||||
|
||||
GuestOps supports exact confirmation lookup, internal reservation notes and stay-date changes for reservations in `Reserved` status. Each change is saved in MongoDB and reviewed by an owner before a single OHIP update attempt. Creation, cancellation, room/rate changes and payment links are not implemented in this milestone.
|
||||
|
||||
## Server configuration
|
||||
|
||||
Copy `deploy/pms.example.json` to a private `pms.local.json` outside your checkout and populate this structure, replacing the hotel ID with the internal GuestOps ID shown on the Reservations page:
|
||||
|
||||
```json
|
||||
{
|
||||
"Pms": {
|
||||
"Hotels": {
|
||||
"REPLACE_WITH_32_CHARACTER_GUESTOPS_HOTEL_ID": {
|
||||
"BaseUrl": "https://YOUR-OHIP-GATEWAY",
|
||||
"HotelCode": "YOUR_PROPERTY_CODE",
|
||||
"ClientId": "YOUR_CLIENT_ID",
|
||||
"ClientSecret": "YOUR_CLIENT_SECRET",
|
||||
"AppKey": "YOUR_APPLICATION_KEY",
|
||||
"EnterpriseId": "YOUR_ENTERPRISE_ID",
|
||||
"Scope": "urn:opc:hgbu:ws:__myscopes__",
|
||||
"WritesEnabled": false,
|
||||
"NoteType": "RESERVATION",
|
||||
"NoteLocation": "GEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the client-credentials grant and property permissions supplied for your OHIP environment. Confirm the note type and notification-location codes with the property. The gateway must be an HTTPS origin, without a path. There is no shared fallback configuration between hotels.
|
||||
|
||||
Set `PMS_CONFIG_FILE_HOST` in the deployment `.env` to the absolute private file path. Compose mounts it read-only into the API; the worker does not receive these credentials. Restrict host permissions to the administrator and the API container's user/group, while allowing that user to read the file. Obtain the image's user ID with `docker run --rm --entrypoint id YOUR_API_IMAGE -u` before assigning permissions. Never put credentials in the browser, repository or an image. Restart the API after configuration changes; this file is not hot-reloaded.
|
||||
|
||||
The default deployment mounts an empty example and makes no OHIP calls. Begin with sandbox credentials and `WritesEnabled: false`. Lookups can be tested without enabling writes. After sandbox acceptance, server write enablement and the hotel's owner-controlled MongoDB setting must both be enabled. Every operation still needs individual owner approval.
|
||||
|
||||
## Review and recovery
|
||||
|
||||
1. Look up the exact confirmation number and check guest, property, dates and booking details.
|
||||
2. Prepare an internal note or changed dates. Preparation saves a proposal; it does not update OHIP. Snapshots and approvals expire after ten minutes.
|
||||
3. For date changes, check availability, rate consequences and guest agreement in the PMS. GuestOps does not calculate or guarantee the resulting price.
|
||||
4. Review and approve. GuestOps re-reads the booking and rejects a changed snapshot before submitting. Only one active operation per hotel/reservation is allowed.
|
||||
5. GuestOps reads the result back. An HTTP success alone is insufficient. A timeout, interrupted request or mismatched result is held for verification and is never automatically replayed.
|
||||
|
||||
Use **Verify current PMS state** for uncertain results. An interrupted `Applying` operation becomes eligible after five minutes; the request deadline is ninety seconds. Verification only reads OHIP. Matching dates prove the observed state, not who changed it. Disabling writes does not prevent verification. Rotating credentials or changing the property binding invalidates old proposals and requires administrator investigation of unresolved operations.
|
||||
|
||||
A proposal can be cancelled before application. An uncertain applied operation cannot be cancelled or force-cleared from the UI: an operator must reconcile it with the PMS and audit history. There is no background retry or unsafe override. Lookup snapshots expire from MongoDB after one day; operation journals retain embedded before/after snapshots. The UI displays the latest 500 journal entries.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
The adapter follows Oracle's [property reservation schema](https://github.com/oracle/hospitality-api-docs/blob/main/rest-api-specs/property/v1/rsv.json), version 26.3.0.0 inspected on 2026-09-10 (SHA-256 `8d95a8a060f2898eee7c9f749c83255ffde426258412c735e3f55180c0c000c1`). Notes use `putReservation`, preserving the existing comment array; dates update arrival/departure without inventing rate data.
|
||||
|
||||
Automated fixture tests cover tenant isolation, concurrent approvals, changed reservations, payload preservation, write controls, interrupted results and read-only reconciliation. They do not call OHIP and are not vendor certification. Before live enablement, validate your property's sandbox responses, note codes and retention, date/rate/inventory effects, credentials and permissions, and interrupted-request handling.
|
||||
|
||||
The adapter does not have a vendor-guaranteed conditional-write transaction. Re-reading before submission reduces stale updates but cannot eliminate a change made by another PMS user between that read and the write. Reservation updates therefore remain explicitly reviewed, narrow operations. Preview mode shows a sample proposal and blocks real PMS actions.
|
||||
@ -17,6 +17,8 @@ public static class Demo
|
||||
foreach (var k in new[] { ("Parking", "Complimentary parking is available in our courtyard, subject to availability. Spaces cannot be reserved.", "parking, car"), ("Check-in and luggage", "Check-in is from 3pm. Guests may leave their luggage with reception before check-in.", "check-in, luggage"), ("Breakfast", "Breakfast is served from 7am to 10am. Please ask our team about dietary requirements.", "breakfast") })
|
||||
await store.Insert(new KnowledgeEntry { HotelId = hotel.Id, Title = k.Item1, Category = "Your stay", Answer = k.Item2, Keywords = k.Item3, Approved = true });
|
||||
await store.Insert(new Activity { HotelId = hotel.Id, UserName = "GuestOps", Action = "Opened an isolated preview workspace" });
|
||||
var sample = new PmsSnapshot { HotelId=hotel.Id,ReservationId="sample-reservation",Confirmation="WH-2481",GuestName="Oliver Brooks",Arrival=DateTime.UtcNow.AddDays(10).ToString("yyyy-MM-dd"),Departure=DateTime.UtcNow.AddDays(12).ToString("yyyy-MM-dd"),RoomType="Garden King",Status="Reserved",Total="320 GBP" };
|
||||
await store.Insert(new PmsChange { HotelId=hotel.Id,ReservationId=sample.ReservationId,Before=sample,Kind="StayDates",Arrival=DateTime.UtcNow.AddDays(17).ToString("yyyy-MM-dd"),Departure=DateTime.UtcNow.AddDays(19).ToString("yyyy-MM-dd"),ProposedBy=user.Id,Detail="Sample proposal for interface review only. No PMS connection or update is available in this workspace." });
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ public abstract class TenantDocument : ITenantDocument
|
||||
}
|
||||
public class Hotel : TenantDocument
|
||||
{
|
||||
public bool PmsUpdatesEnabled { get; set; }
|
||||
public bool AiDraftsEnabled { get; set; }
|
||||
public bool StaffSendingEnabled { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
142
src/GuestOps.Api/OhipClient.cs
Normal file
142
src/GuestOps.Api/OhipClient.cs
Normal file
@ -0,0 +1,142 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public sealed class OhipTokens
|
||||
{
|
||||
readonly ConcurrentDictionary<string, (string Token, DateTime Until)> cache = new();
|
||||
readonly SemaphoreSlim gate = new(1,1);
|
||||
public async Task<string> Get(string hotel, OhipProfile profile, HttpClient http, CancellationToken ct)
|
||||
{
|
||||
var key = hotel + ":" + profile.Revision;
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (cache.TryGetValue(key, out var hit) && hit.Until > DateTime.UtcNow) return hit.Token;
|
||||
foreach (var old in cache.Where(x => x.Value.Until <= DateTime.UtcNow).Select(x => x.Key)) cache.TryRemove(old, out _);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, profile.BaseUrl.TrimEnd('/') + "/oauth/v1/tokens");
|
||||
request.Headers.Authorization = new("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(profile.ClientId + ":" + profile.ClientSecret)));
|
||||
request.Headers.Add("x-app-key", profile.AppKey);
|
||||
if (profile.EnterpriseId.Length > 0) request.Headers.Add("enterpriseId", profile.EnterpriseId);
|
||||
request.Content = new FormUrlEncodedContent(new Dictionary<string,string> { ["grant_type"] = "client_credentials", ["scope"] = profile.Scope });
|
||||
using var response = await http.SendAsync(request, ct); response.EnsureSuccessStatusCode();
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
|
||||
var token = json.RootElement.GetProperty("access_token").GetString();
|
||||
if (string.IsNullOrWhiteSpace(token)) throw new PmsInvalid("OHIP did not issue an access token.");
|
||||
var seconds = json.RootElement.TryGetProperty("expires_in", out var expiry) && expiry.TryGetInt32(out var value) ? value : 60;
|
||||
cache[key] = (token, DateTime.UtcNow.AddSeconds(Math.Clamp(seconds - 60, 0, 3600)));
|
||||
return token;
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class OhipClient(HttpClient http, OhipTokens tokens)
|
||||
{
|
||||
static string Segment(string value) => Uri.EscapeDataString(value);
|
||||
static string Route(OhipProfile p) => "/rsv/v1/hotels/" + Segment(p.HotelCode) + "/reservations";
|
||||
async Task<HttpRequestMessage> Request(string hotel, OhipProfile p, HttpMethod method, string path, CancellationToken ct)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, p.BaseUrl.TrimEnd('/') + path);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await tokens.Get(hotel, p, http, ct));
|
||||
request.Headers.Add("x-app-key", p.AppKey); request.Headers.Add("x-hotelid", p.HotelCode);
|
||||
request.Headers.Accept.Add(new("application/json")); return request;
|
||||
}
|
||||
async Task<JsonElement> Read(string hotel, OhipProfile p, string path, CancellationToken ct)
|
||||
{
|
||||
using var request = await Request(hotel, p, HttpMethod.Get, path, ct);
|
||||
using var response = await http.SendAsync(request, ct); response.EnsureSuccessStatusCode();
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); return json.RootElement.Clone();
|
||||
}
|
||||
static JsonElement[] Reservations(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("reservations", out var r)) throw new PmsInvalid("OHIP returned an unsupported reservation response.");
|
||||
if (r.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (r.TryGetProperty("hasMore", out var more) && more.ValueKind == JsonValueKind.True) throw new PmsInvalid("The reservation lookup returned more results than can be safely matched.");
|
||||
r = r.GetProperty("reservation");
|
||||
}
|
||||
return r.ValueKind == JsonValueKind.Array ? r.EnumerateArray().ToArray() : r.ValueKind == JsonValueKind.Object ? [r] : throw new PmsInvalid("No reservation returned.");
|
||||
}
|
||||
static string Text(JsonElement element, params string[] path)
|
||||
{
|
||||
foreach (var key in path) { if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(key, out element)) return ""; }
|
||||
return element.ValueKind == JsonValueKind.String ? element.GetString()! : element.ValueKind == JsonValueKind.Number ? element.GetRawText() : "";
|
||||
}
|
||||
static string Id(JsonElement r, string kind)
|
||||
{
|
||||
if (!r.TryGetProperty("reservationIdList", out var ids) || ids.ValueKind != JsonValueKind.Array) return "";
|
||||
var matches = ids.EnumerateArray().Where(x => Text(x,"type") == kind).Select(x => Text(x,"id")).Distinct().ToArray();
|
||||
return matches.Length == 1 ? matches[0] : "";
|
||||
}
|
||||
public async Task<PmsSnapshot> Lookup(string hotel, OhipProfile p, string confirmation, CancellationToken ct)
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(confirmation, "^[a-zA-Z0-9-]{1,80}$")) throw new PmsInvalid("Enter the exact PMS confirmation number.");
|
||||
var json = await Read(hotel,p,Route(p)+"?confirmationNumberList="+Segment(confirmation)+"&limit=100",ct);
|
||||
var matches = Reservations(json).Where(r => Id(r,"Confirmation").Equals(confirmation,StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||
if (matches.Length != 1 || string.IsNullOrWhiteSpace(Id(matches[0],"Reservation"))) throw new PmsInvalid("No single exact reservation matched that confirmation. Check it in the PMS.");
|
||||
return await Fetch(hotel,p,Id(matches[0],"Reservation"),confirmation,ct);
|
||||
}
|
||||
public async Task<PmsSnapshot> Fetch(string hotel, OhipProfile p, string id, string confirmation, CancellationToken ct)
|
||||
{
|
||||
var json = await Read(hotel,p,Route(p)+"/"+Segment(id)+"?fetchInstructions=Reservation&fetchInstructions=Comments&fetchInstructions=TotalCostOfStay",ct);
|
||||
var rows = Reservations(json);
|
||||
if (rows.Length != 1 || Id(rows[0],"Reservation") != id || !Id(rows[0],"Confirmation").Equals(confirmation,StringComparison.OrdinalIgnoreCase) || Text(rows[0],"hotelId") != p.HotelCode) throw new PmsInvalid("OHIP reservation identity did not match this hotel's request.");
|
||||
var row = rows[0]; var arrival=Text(row,"roomStay","arrivalDate"); var departure=Text(row,"roomStay","departureDate");
|
||||
if (!DateOnly.TryParseExact(arrival,"yyyy-MM-dd",CultureInfo.InvariantCulture,DateTimeStyles.None,out _) || !DateOnly.TryParseExact(departure,"yyyy-MM-dd",CultureInfo.InvariantCulture,DateTimeStyles.None,out _)) throw new PmsInvalid("The PMS returned incomplete stay dates.");
|
||||
var comments = row.TryGetProperty("comments",out var c) ? c : JsonSerializer.SerializeToElement(Array.Empty<object>());
|
||||
if (comments.ValueKind != JsonValueKind.Array) throw new PmsInvalid("OHIP comment format does not match the supported schema.");
|
||||
string guest="",room="";
|
||||
if (row.TryGetProperty("reservationGuests",out var guests) && guests.ValueKind==JsonValueKind.Array)
|
||||
{
|
||||
var primary=guests.EnumerateArray().Where(g=>g.TryGetProperty("primary",out var flag)&&flag.ValueKind==JsonValueKind.True).ToArray();
|
||||
if(primary.Length==1 && primary[0].TryGetProperty("profileInfo",out var info) && info.TryGetProperty("profile",out var profile) && profile.TryGetProperty("customer",out var customer) && customer.TryGetProperty("personName",out var names) && names.ValueKind==JsonValueKind.Array)
|
||||
{var name=names.EnumerateArray().FirstOrDefault(n=>Text(n,"nameType")=="Primary");if(name.ValueKind!=JsonValueKind.Undefined)guest=(Text(name,"givenName")+" "+Text(name,"surname")).Trim();}
|
||||
}
|
||||
if(row.TryGetProperty("roomStay",out var stay)&&stay.TryGetProperty("roomRates",out var rates)&&rates.ValueKind==JsonValueKind.Array)room=string.Join(", ",rates.EnumerateArray().Select(r=>Text(r,"roomType")).Where(r=>r.Length>0).Distinct());
|
||||
var relevant = new JsonObject();
|
||||
foreach(var key in new[]{"reservationIdList","hotelId","roomStay","comments","reservationStatus","lastModifyDateTime","reservationGuests"}) if(row.TryGetProperty(key,out var field)) relevant[key]=JsonNode.Parse(field.GetRawText());
|
||||
return new PmsSnapshot { HotelId=hotel,ReservationId=id,Confirmation=confirmation,GuestName=guest,Arrival=arrival,Departure=departure,Status=Text(row,"reservationStatus"),RoomType=room,Total=(Text(row,"roomStay","total","amountAfterTax")+" "+Text(row,"roomStay","total","currencyCode")).Trim(),CommentsJson=comments.GetRawText(),ConnectionRevision=p.Revision,Fingerprint=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Canonical(relevant)))) };
|
||||
}
|
||||
static string Canonical(JsonNode? n) => n is JsonObject obj ? "{"+string.Join(",",obj.OrderBy(x=>x.Key,StringComparer.Ordinal).Select(x=>JsonSerializer.Serialize(x.Key)+":"+Canonical(x.Value)))+"}" : n is JsonArray arr ? "["+string.Join(",",arr.Select(Canonical))+"]" : n?.ToJsonString()??"null";
|
||||
public static string Payload(PmsChange change, OhipProfile profile)
|
||||
{
|
||||
var reservation=new JsonObject { ["hotelId"]=profile.HotelCode,["reservationIdList"]=JsonSerializer.SerializeToNode(new[]{new{id=change.ReservationId,type="Reservation"}}) };
|
||||
if(change.Kind=="StayDates")reservation["roomStay"]=new JsonObject{["arrivalDate"]=change.Arrival,["departureDate"]=change.Departure};
|
||||
else if(change.Kind=="AddNote")
|
||||
{
|
||||
var comments=JsonNode.Parse(change.Before.CommentsJson)!.AsArray();
|
||||
if(comments.Count>=3999)throw new PmsInvalid("The reservation has too many notes to update safely.");
|
||||
comments.Add(new JsonObject { ["comment"]=new JsonObject { ["text"]=new JsonObject{["value"]=change.Note},["commentTitle"]="GuestOps "+change.Id,["type"]=profile.NoteType,["notificationLocation"]=profile.NoteLocation,["internal"]=true } });
|
||||
reservation["comments"]=comments;
|
||||
}
|
||||
else throw new PmsInvalid("Unsupported PMS operation.");
|
||||
return new JsonObject{["reservations"]=new JsonArray(reservation)}.ToJsonString();
|
||||
}
|
||||
public async Task<HttpRequestMessage> Prepare(PmsChange change,OhipProfile profile,CancellationToken ct)
|
||||
{
|
||||
var request=await Request(change.HotelId,profile,HttpMethod.Put,Route(profile)+"/"+Segment(change.ReservationId),ct);
|
||||
request.Headers.Add("X-Request-Id",change.Id);
|
||||
request.Content=new StringContent(Payload(change,profile),Encoding.UTF8,"application/json");return request;
|
||||
}
|
||||
public async Task Write(HttpRequestMessage request,CancellationToken ct)
|
||||
{
|
||||
// No retry or redirect handler: all failures after entry are uncertain writes.
|
||||
using var response=await http.SendAsync(request,ct); response.EnsureSuccessStatusCode();
|
||||
using var json=JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
|
||||
if(json.RootElement.TryGetProperty("errors",out var errors)&&errors.ValueKind!=JsonValueKind.Null)throw new PmsInvalid("OHIP reported an update error.");
|
||||
if(json.RootElement.TryGetProperty("warnings",out var warnings)&&warnings.ValueKind==JsonValueKind.Array&&warnings.GetArrayLength()>0)throw new PmsInvalid("OHIP returned warnings requiring review.");
|
||||
}
|
||||
public static bool Matches(PmsChange change,PmsSnapshot after)
|
||||
{
|
||||
if(after.ReservationId!=change.ReservationId||after.HotelId!=change.HotelId)return false;
|
||||
if(change.Kind=="StayDates")return after.Arrival==change.Arrival&&after.Departure==change.Departure;
|
||||
using var json=JsonDocument.Parse(after.CommentsJson);
|
||||
return change.Kind=="AddNote" && json.RootElement.EnumerateArray().Count(n=>Text(n,"comment","commentTitle")=="GuestOps "+change.Id&&Text(n,"comment","text","value")==change.Note)==1;
|
||||
}
|
||||
}
|
||||
51
src/GuestOps.Api/PmsEndpoints.cs
Normal file
51
src/GuestOps.Api/PmsEndpoints.cs
Normal file
@ -0,0 +1,51 @@
|
||||
using System.Security.Claims;
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public static class PmsEndpoints
|
||||
{
|
||||
public static void Map(RouteGroupBuilder api,bool preview)
|
||||
{
|
||||
var group=api.MapGroup("/pms").RequireRateLimiting("pms");
|
||||
group.MapGet("/status",(HttpContext c,IConfiguration config)=>
|
||||
{
|
||||
var profile=preview?null:OhipProfile.Read(config,Session.Hotel(c));
|
||||
return Results.Ok(new{configured=profile!=null,writesConfigured=profile?.WritesEnabled==true,hotelCode=profile?.HotelCode??"",preview});
|
||||
});
|
||||
group.MapPost("/lookup",async(PmsLookupInput input,HttpContext c,PmsWork work)=>
|
||||
{
|
||||
if(preview)return Results.BadRequest(new{error="Real PMS connections are unavailable in preview."});
|
||||
var snapshot=await work.Lookup(Session.Hotel(c),input.Confirmation,c.RequestAborted);return Results.Ok(snapshot.View());
|
||||
});
|
||||
group.MapGet("/changes",async(HttpContext c,IStore store)=>Results.Ok((await store.List<PmsChange>(Session.Hotel(c))).OrderByDescending(x=>x.UpdatedAt).Select(x=>x.View())));
|
||||
group.MapPost("/changes",async(PmsProposeInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var snapshot=await store.Get<PmsSnapshot>(Session.Hotel(c),input.SnapshotId);if(snapshot==null)return Results.NotFound();
|
||||
if(preview)return Results.BadRequest();
|
||||
var change=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,snapshot,input);
|
||||
await Session.Audit(store,c,"Prepared a PMS change for review");return Results.Ok(change.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
group.MapPost("/changes/{id}/apply",async(string id,PmsApproveInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Apply(change,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.AvailabilityAndPriceChecked,c.RequestAborted);
|
||||
await Session.Audit(store,c,"PMS change result: "+result.State);return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
group.MapPost("/changes/{id}/verify",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Verify(change,input.Version,c.RequestAborted);await Session.Audit(store,c,"Verified PMS state: "+result.State);return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
group.MapPost("/changes/{id}/cancel",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=>
|
||||
{
|
||||
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
|
||||
var result=await work.Cancel(change,input.Version);await Session.Audit(store,c,"Cancelled an unapplied PMS proposal");return Results.Ok(result.View());
|
||||
}).RequireAuthorization("Owner");
|
||||
group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>
|
||||
{
|
||||
if(input.Enabled&&(preview||OhipProfile.Read(config,Session.Hotel(c))?.WritesEnabled!=true))return Results.BadRequest(new{error="Server-side PMS writes must be enabled after sandbox acceptance first."});
|
||||
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PmsUpdatesEnabled=input.Enabled;hotel.Version=input.Version+1;
|
||||
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();
|
||||
await Session.Audit(store,c,"Updated PMS write controls");return Results.Ok(hotel);
|
||||
}).RequireAuthorization("Owner");
|
||||
}
|
||||
}
|
||||
75
src/GuestOps.Api/PmsModels.cs
Normal file
75
src/GuestOps.Api/PmsModels.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public sealed class PmsSnapshot : TenantDocument
|
||||
{
|
||||
public string ReservationId { get; set; } = "";
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string GuestName { get; set; } = "";
|
||||
public string Arrival { get; set; } = "";
|
||||
public string Departure { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string RoomType { get; set; } = "";
|
||||
public string Total { get; set; } = "";
|
||||
public string CommentsJson { get; set; } = "[]";
|
||||
public string Fingerprint { get; set; } = "";
|
||||
public string ConnectionRevision { get; set; } = "";
|
||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||
public object View() => new { Id, ReservationId, Confirmation, GuestName, Arrival, Departure, Status, RoomType, Total, FetchedAt };
|
||||
}
|
||||
public sealed class PmsChange : TenantDocument
|
||||
{
|
||||
public string ReservationId { get; set; } = "";
|
||||
public PmsSnapshot Before { get; set; } = new();
|
||||
public PmsSnapshot? After { get; set; }
|
||||
public string Kind { get; set; } = "";
|
||||
public string Arrival { get; set; } = "";
|
||||
public string Departure { get; set; } = "";
|
||||
public string Note { get; set; } = "";
|
||||
public string State { get; set; } = "Review";
|
||||
public string Detail { get; set; } = "Review the reservation and proposed change before applying.";
|
||||
public string ProposedBy { get; set; } = "";
|
||||
public string ApprovedBy { get; set; } = "";
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddMinutes(10);
|
||||
public long Version { get; set; }
|
||||
public static bool Active(string state) => state is "Review" or "Applying" or "NeedsReview";
|
||||
public object View() => new { Id, ReservationId, Kind, Arrival, Departure, Note, State, Detail, ProposedBy, ApprovedBy, UpdatedAt, ExpiresAt, Version, before = Before.View(), after = After?.View() };
|
||||
}
|
||||
public sealed record PmsLookupInput(string Confirmation);
|
||||
public sealed record PmsProposeInput(string SnapshotId, string Kind, string Arrival, string Departure, string Note);
|
||||
public sealed record PmsApproveInput(long Version, bool AvailabilityAndPriceChecked);
|
||||
public sealed record PmsControlsInput(long Version, bool Enabled);
|
||||
public sealed class PmsConflict(string message) : Exception(message);
|
||||
public sealed class PmsInvalid(string message) : Exception(message);
|
||||
|
||||
public sealed class OhipProfile
|
||||
{
|
||||
public string BaseUrl { get; set; } = "";
|
||||
public string HotelCode { get; set; } = "";
|
||||
public string ClientId { get; set; } = "";
|
||||
public string ClientSecret { get; set; } = "";
|
||||
public string AppKey { get; set; } = "";
|
||||
public string EnterpriseId { get; set; } = "";
|
||||
public string Scope { get; set; } = "urn:opc:hgbu:ws:__myscopes__";
|
||||
public bool WritesEnabled { get; set; }
|
||||
public string NoteType { get; set; } = "RESERVATION";
|
||||
public string NoteLocation { get; set; } = "GEN";
|
||||
// Write enablement is a stop control, not a credential identity: verification
|
||||
// must remain possible after an administrator turns writes off.
|
||||
public string Revision => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { BaseUrl, HotelCode, ClientId, ClientSecret, AppKey, EnterpriseId, Scope, NoteType, NoteLocation }))));
|
||||
public static OhipProfile? Read(IConfiguration config, string hotel)
|
||||
{
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(hotel, "^[a-f0-9]{32}$")) return null;
|
||||
var section = config.GetSection("Pms:Hotels:" + hotel);
|
||||
if (!section.Exists()) return null;
|
||||
var profile = section.Get<OhipProfile>();
|
||||
if (profile == null || !Uri.TryCreate(profile.BaseUrl, UriKind.Absolute, out var uri) || uri.Scheme != "https" || uri.Port != 443 || uri.AbsolutePath != "/" || uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0 || uri.IsLoopback) throw new PmsInvalid("The administrator must configure a valid HTTPS OHIP origin.");
|
||||
foreach (var value in new[] { profile.HotelCode, profile.ClientId, profile.ClientSecret, profile.AppKey, profile.Scope, profile.NoteType, profile.NoteLocation })
|
||||
if (string.IsNullOrWhiteSpace(value) || value.Length > 4000 || value.IndexOfAny(['\r','\n']) >= 0) throw new PmsInvalid("The administrator must complete the OHIP connection settings.");
|
||||
if (profile.HotelCode.Length > 20 || profile.NoteType.Length > 20 || profile.NoteLocation.Length > 20 || profile.ClientId.Contains(':')) throw new PmsInvalid("Invalid OHIP identifiers.");
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
80
src/GuestOps.Api/PmsWork.cs
Normal file
80
src/GuestOps.Api/PmsWork.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using System.Globalization;
|
||||
namespace GuestOps.Web;
|
||||
|
||||
public sealed class PmsWork(IStore store,OhipClient ohip,IConfiguration config)
|
||||
{
|
||||
public OhipProfile Profile(string hotel)=>OhipProfile.Read(config,hotel)??throw new PmsInvalid("Your administrator has not configured OHIP for this hotel.");
|
||||
async Task WritesAllowed(string hotel,OhipProfile profile)
|
||||
{
|
||||
if(!profile.WritesEnabled||(await store.Get<Hotel>(hotel,hotel))?.PmsUpdatesEnabled!=true)throw new PmsInvalid("PMS updates are disabled. Enable them only after sandbox acceptance.");
|
||||
}
|
||||
public async Task<PmsSnapshot> Lookup(string hotel,string confirmation,CancellationToken ct)
|
||||
{
|
||||
var snapshot=await ohip.Lookup(hotel,Profile(hotel),confirmation,ct);await store.Insert(snapshot);return snapshot;
|
||||
}
|
||||
public async Task<PmsChange> Propose(string hotel,string user,PmsSnapshot snapshot,PmsProposeInput input)
|
||||
{
|
||||
var profile=Profile(hotel);
|
||||
if(snapshot.HotelId!=hotel||snapshot.ConnectionRevision!=profile.Revision||snapshot.FetchedAt<DateTime.UtcNow.AddMinutes(-10))throw new PmsConflict("Look up the reservation again before preparing a change.");
|
||||
if(input.Kind is not ("StayDates" or "AddNote"))throw new PmsInvalid("Choose a supported PMS action.");
|
||||
var change=new PmsChange{HotelId=hotel,ReservationId=snapshot.ReservationId,Before=snapshot,Kind=input.Kind,ProposedBy=user};
|
||||
if(input.Kind=="StayDates")
|
||||
{
|
||||
if(snapshot.Status!="Reserved")throw new PmsInvalid("Only reserved stays can have dates changed here. Handle other statuses in the PMS.");
|
||||
if(!DateOnly.TryParseExact(input.Arrival,"yyyy-MM-dd",CultureInfo.InvariantCulture,DateTimeStyles.None,out var arrival)||!DateOnly.TryParseExact(input.Departure,"yyyy-MM-dd",CultureInfo.InvariantCulture,DateTimeStyles.None,out var departure)||departure<=arrival||departure.DayNumber-arrival.DayNumber>365)throw new PmsInvalid("Enter valid arrival and departure dates for a stay of at most 365 nights.");
|
||||
if(input.Arrival==snapshot.Arrival&&input.Departure==snapshot.Departure)throw new PmsInvalid("The proposed dates are unchanged.");
|
||||
change.Arrival=input.Arrival;change.Departure=input.Departure;
|
||||
}
|
||||
else
|
||||
{
|
||||
if(!Input.Text(input.Note,1,2000))throw new PmsInvalid("Enter a reservation note of at most 2,000 characters.");
|
||||
change.Note=input.Note.Trim();
|
||||
}
|
||||
_=OhipClient.Payload(change,profile);
|
||||
if(!await store.TryInsertPmsChange(change))throw new PmsConflict("A change is already being reviewed or needs reconciliation for this reservation. Resolve it in the change history first.");
|
||||
return change;
|
||||
}
|
||||
async Task Save(PmsChange change,string state,string detail)
|
||||
{
|
||||
var version=change.Version;change.Version++;change.State=state;change.Detail=detail;change.UpdatedAt=DateTime.UtcNow;
|
||||
if(!await store.Replace(change.HotelId,change.Id,version,change))throw new PmsConflict("This change was updated elsewhere. Refresh its status.");
|
||||
}
|
||||
public async Task<PmsChange> Apply(PmsChange change,long version,string approver,bool priceChecked,CancellationToken requestToken)
|
||||
{
|
||||
if(change.State!="Review"||change.Version!=version)throw new PmsConflict("Only the current reviewed proposal can be applied once.");
|
||||
if(change.ExpiresAt<DateTime.UtcNow)throw new PmsConflict("This proposal expired. Cancel it and look up the reservation again.");
|
||||
if(change.Kind=="StayDates"&&!priceChecked)throw new PmsInvalid("Check availability, rate consequences and guest agreement in the PMS before approving these dates.");
|
||||
var profile=Profile(change.HotelId);await WritesAllowed(change.HotelId,profile);
|
||||
if(profile.Revision!=change.Before.ConnectionRevision)throw new PmsConflict("OHIP configuration changed. Cancel this proposal and look up the reservation again.");
|
||||
change.ApprovedBy=approver;
|
||||
await Save(change,"Applying","Checking the current PMS reservation before submitting the approved change.");
|
||||
using var deadline=CancellationTokenSource.CreateLinkedTokenSource(requestToken);deadline.CancelAfter(TimeSpan.FromSeconds(90));
|
||||
bool submitted=false;
|
||||
try
|
||||
{
|
||||
var current=await ohip.Fetch(change.HotelId,profile,change.ReservationId,change.Before.Confirmation,deadline.Token);
|
||||
if(current.Fingerprint!=change.Before.Fingerprint){await Save(change,"NotApplied","The PMS reservation changed after lookup. No update was sent. Prepare a fresh proposal.");return change;}
|
||||
using var request=await ohip.Prepare(change,profile,deadline.Token);
|
||||
await WritesAllowed(change.HotelId,profile);deadline.Token.ThrowIfCancellationRequested();
|
||||
submitted=true;await ohip.Write(request,deadline.Token);
|
||||
var after=await ohip.Fetch(change.HotelId,profile,change.ReservationId,change.Before.Confirmation,deadline.Token);change.After=after;
|
||||
if(!OhipClient.Matches(change,after))throw new PmsConflict("The PMS response did not confirm the intended state.");
|
||||
await Save(change,"Applied","The requested state was confirmed by reading the reservation back from OHIP. Check rate consequences in the PMS.");
|
||||
}
|
||||
catch(Exception) { await Save(change,submitted?"NeedsReview":"NotApplied",submitted?"The PMS update outcome is uncertain. Verify current PMS state; this operation will not be replayed.":"The pre-update check failed. No PMS update was submitted. Check the connection and prepare a new proposal."); }
|
||||
return change;
|
||||
}
|
||||
public async Task<PmsChange> Verify(PmsChange change,long version,CancellationToken ct)
|
||||
{
|
||||
if(change.Version!=version || !(change.State=="NeedsReview" || change.State=="Applying"&&change.UpdatedAt<DateTime.UtcNow.AddMinutes(-5)))throw new PmsConflict("Only an uncertain or interrupted update can be verified.");
|
||||
var profile=Profile(change.HotelId);
|
||||
if(profile.Revision!=change.Before.ConnectionRevision)throw new PmsConflict("The connection changed. Restore the original hotel binding before reconciliation.");
|
||||
var after=await ohip.Fetch(change.HotelId,profile,change.ReservationId,change.Before.Confirmation,ct);change.After=after;
|
||||
await Save(change,OhipClient.Matches(change,after)?"Applied":"NeedsReview",OhipClient.Matches(change,after)?"The intended state is now confirmed in OHIP. This observation does not identify who made the change.":"The intended state is not confirmed. Keep this update on hold and reconcile manually in the PMS; no retry was queued.");return change;
|
||||
}
|
||||
public async Task<PmsChange> Cancel(PmsChange change,long version)
|
||||
{
|
||||
if(change.State!="Review"||change.Version!=version)throw new PmsConflict("Only a proposal that has not started can be cancelled.");
|
||||
await Save(change,"Cancelled","The proposal was cancelled before any PMS update.");return change;
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ using System.Net;
|
||||
|
||||
var bootstrap = args.Contains("--bootstrap");
|
||||
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap").ToArray());
|
||||
if (builder.Configuration["Pms:ConfigFile"] is { Length: > 0 } pmsConfigFile) builder.Configuration.AddJsonFile(pmsConfigFile,optional:false,reloadOnChange:false);
|
||||
builder.Logging.ClearProviders(); builder.Logging.AddConsole();
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning);
|
||||
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
|
||||
@ -27,6 +28,9 @@ builder.Services.AddSingleton<IStore>(s => preview ? new PreviewStore() : new Mo
|
||||
builder.Services.AddSingleton<IPasswordHasher<StaffUser>, PasswordHasher<StaffUser>>();
|
||||
builder.Services.AddHttpClient<GoogleMailbox>(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
builder.Services.AddHttpClient<AiDrafts>(c => c.Timeout = TimeSpan.FromSeconds(60)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
builder.Services.AddSingleton<OhipTokens>();
|
||||
builder.Services.AddHttpClient<OhipClient>(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
builder.Services.AddTransient<PmsWork>();
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax;
|
||||
@ -55,6 +59,7 @@ builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.N
|
||||
builder.Services.AddRateLimiter(o =>
|
||||
{
|
||||
o.RejectionStatusCode = 429;
|
||||
o.AddPolicy("pms", context => RateLimitPartition.GetFixedWindowLimiter(context.User.FindFirstValue("hotel") ?? "anonymous", _ => new() { PermitLimit = 30, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
o.AddPolicy("ai", context => RateLimitPartition.GetFixedWindowLimiter(context.User.FindFirstValue("hotel") ?? "anonymous", _ => new() { PermitLimit = 6, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
o.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||
});
|
||||
@ -82,6 +87,8 @@ app.Use(async (ctx, next) =>
|
||||
ctx.Response.Headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
||||
if (ctx.Request.Path.StartsWithSegments("/api")) ctx.Response.Headers.CacheControl = "no-store";
|
||||
try { await next(); }
|
||||
catch (PmsInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
catch (PmsConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); }
|
||||
catch (AntiforgeryValidationException) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Your session needs refreshing. Reload the page and try again." }); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -116,6 +123,7 @@ app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPassword
|
||||
app.MapPost("/api/auth/logout", async (HttpContext c, CancellationToken _) => { await c.SignOutAsync(); return Results.Ok(); }).RequireAuthorization();
|
||||
if (preview) app.MapPost("/api/preview/start", async (HttpContext c, CancellationToken _) => { var user = await Demo.Seed(store); await Session.SignIn(c, user); return Results.Ok(); }).RequireRateLimiting("login");
|
||||
var api = app.MapGroup("/api").RequireAuthorization();
|
||||
PmsEndpoints.Map(api,preview);
|
||||
api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get<Hotel>(Session.Hotel(c), Session.Hotel(c))));
|
||||
api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
|
||||
{
|
||||
@ -261,4 +269,3 @@ namespace GuestOps.Web
|
||||
public static IResult Conflict() => Results.Conflict(new { error = "Someone changed this record. Reload it before saving again." });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -62,4 +62,3 @@ public sealed class ReplyDelivery(IStore store, GoogleMailbox google)
|
||||
catch { await Save("NeedsReview", "Gmail's delivery result is uncertain. Verify delivery; this reply will not be automatically sent again."); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ public interface IStore
|
||||
Task ReleaseLease(string id, string owner);
|
||||
Task Import(Conversation message);
|
||||
Task<List<Conversation>> Deliveries();
|
||||
Task<bool> TryInsertPmsChange(PmsChange change);
|
||||
}
|
||||
public sealed class MongoStore : IStore
|
||||
{
|
||||
@ -41,6 +42,9 @@ public sealed class MongoStore : IStore
|
||||
}
|
||||
public async Task Initialize()
|
||||
{
|
||||
await Collection<PmsChange>().Indexes.CreateOneAsync(new CreateIndexModel<PmsChange>(Builders<PmsChange>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.ReservationId),new CreateIndexOptions<PmsChange>{Unique=true,PartialFilterExpression=Builders<PmsChange>.Filter.In(x=>x.State,new[]{"Review","Applying","NeedsReview"})}));
|
||||
await Collection<PmsChange>().Indexes.CreateOneAsync(new CreateIndexModel<PmsChange>(Builders<PmsChange>.IndexKeys.Ascending(x=>x.HotelId).Descending(x=>x.UpdatedAt)));
|
||||
await Collection<PmsSnapshot>().Indexes.CreateOneAsync(new CreateIndexModel<PmsSnapshot>(Builders<PmsSnapshot>.IndexKeys.Ascending(x=>x.FetchedAt),new(){ExpireAfter=TimeSpan.FromDays(1)}));
|
||||
await Collection<StaffUser>().Indexes.CreateOneAsync(new CreateIndexModel<StaffUser>(Builders<StaffUser>.IndexKeys.Ascending(x => x.Email), new() { Unique = true }));
|
||||
await Collection<Conversation>().Indexes.CreateOneAsync(new CreateIndexModel<Conversation>(Builders<Conversation>.IndexKeys.Ascending(x => x.HotelId).Ascending(x => x.MailboxId).Ascending(x => x.ProviderMessageId), new() { Unique = true }));
|
||||
await Collection<Conversation>().Indexes.CreateOneAsync(new CreateIndexModel<Conversation>(Builders<Conversation>.IndexKeys.Ascending(x => x.HotelId).Descending(x => x.ReceivedAt)));
|
||||
@ -53,6 +57,7 @@ public sealed class MongoStore : IStore
|
||||
var query = Collection<T>().Find(Scope<T>(hotel));
|
||||
if (typeof(T) == typeof(Conversation)) query = query.Sort(Builders<T>.Sort.Descending("ReceivedAt"));
|
||||
if (typeof(T) == typeof(Activity)) query = query.Sort(Builders<T>.Sort.Descending("At"));
|
||||
if (typeof(T) == typeof(PmsChange)) query = query.Sort(Builders<T>.Sort.Descending("UpdatedAt"));
|
||||
return query.Limit(500).ToListAsync();
|
||||
}
|
||||
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();
|
||||
@ -93,11 +98,24 @@ public sealed class MongoStore : IStore
|
||||
try { await Insert(message); }
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { /* already durable */ }
|
||||
}
|
||||
public async Task<bool> TryInsertPmsChange(PmsChange change)
|
||||
{
|
||||
try { await Insert(change);return true; }
|
||||
catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return false;}
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit Development-only preview store. Production never falls back to this.
|
||||
public sealed class PreviewStore : IStore
|
||||
{
|
||||
public Task<bool> TryInsertPmsChange(PmsChange change)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
if(rows.Where(x=>x.Key.StartsWith("PmsChange:")).Select(x=>Clone<PmsChange>(x.Value)).Any(x=>x.HotelId==change.HotelId&&x.ReservationId==change.ReservationId&&PmsChange.Active(x.State)))return Task.FromResult(false);
|
||||
return Task.FromResult(rows.TryAdd(Key<PmsChange>(change.Id),Json(change)));
|
||||
}
|
||||
}
|
||||
public Task<List<Conversation>> Deliveries() => Task.FromResult(rows.Where(x => x.Key.StartsWith("Conversation:")).Select(x => Clone<Conversation>(x.Value)).Where(x => x.Delivery?.State is "Pending" or "Sending").ToList());
|
||||
private readonly ConcurrentDictionary<string, string> rows = new();
|
||||
private readonly object gate = new();
|
||||
|
||||
@ -69,4 +69,3 @@ sealed class DeliveryWorker(IStore store, IServiceScopeFactory factory, ILogger<
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
tests/GuestOps.Tests/PmsTests.cs
Normal file
114
tests/GuestOps.Tests/PmsTests.cs
Normal file
@ -0,0 +1,114 @@
|
||||
using GuestOps.Web;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
static class PmsTests
|
||||
{
|
||||
public static async Task Run(Action<string,bool> check,IStore store)
|
||||
{
|
||||
var hotel=new Hotel{PmsUpdatesEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel);
|
||||
var prefix="Pms:Hotels:"+hotel.Id+":";
|
||||
var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{[prefix+"BaseUrl"]="https://ohip.example.invalid",[prefix+"HotelCode"]="TEST01",[prefix+"ClientId"]="client",[prefix+"ClientSecret"]="fixture-secret",[prefix+"AppKey"]="fixture-app-key",[prefix+"WritesEnabled"]="true"}).Build();
|
||||
var handler=new Fixture();var tokens=new OhipTokens();var client=new OhipClient(new HttpClient(handler),tokens);var work=new PmsWork(store,client,config);
|
||||
async Task<bool> Blocked(Func<Task> action){try{await action();return false;}catch(PmsConflict){return true;}catch(PmsInvalid){return true;}}
|
||||
var snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
check("OHIP exact lookup returns typed reservation and primary guest",snapshot.ReservationId=="RES123"&&snapshot.GuestName=="Alex Guest"&&snapshot.Arrival=="2030-10-12");
|
||||
await work.Lookup(hotel.Id,"CONF123",default);check("OHIP access token is cached within the hotel binding",handler.TokenCalls==1);
|
||||
handler.WrongDetailId=true;check("OHIP mismatched detail identity fails closed",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.WrongDetailId=false;
|
||||
handler.Ambiguous=true;check("OHIP ambiguous exact confirmations are rejected",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.Ambiguous=false;
|
||||
handler.HasMore=true;check("OHIP incomplete search page is not assumed unique",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.HasMore=false;
|
||||
handler.WrongProperty=true;check("OHIP foreign property response is rejected",await Blocked(()=>work.Lookup(hotel.Id,"CONF123",default)));handler.WrongProperty=false;
|
||||
check("Lookup validates confirmation before provider access",await Blocked(()=>work.Lookup(hotel.Id,"x&hotelId=OTHER",default)));
|
||||
var proposal=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Guest arrives late."));
|
||||
check("PMS proposal is durable without an external write",handler.Writes==0&&(await store.Get<PmsChange>(hotel.Id,proposal.Id))?.State=="Review");
|
||||
check("One active proposal per hotel reservation",await Blocked(()=>work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Duplicate"))));
|
||||
check("Another hotel cannot read pending PMS changes",await store.Get<PmsChange>("other",proposal.Id)==null);
|
||||
check("PMS approval rejects stale version",await Blocked(()=>work.Apply(proposal,3,"owner",false,default))&&handler.Writes==0);
|
||||
var one=(await store.Get<PmsChange>(hotel.Id,proposal.Id))!;var two=(await store.Get<PmsChange>(hotel.Id,proposal.Id))!;
|
||||
await Task.WhenAll(Blocked(()=>work.Apply(one,0,"owner",false,default)),Blocked(()=>work.Apply(two,0,"owner",false,default)));
|
||||
var applied=(await store.Get<PmsChange>(hotel.Id,proposal.Id))!;
|
||||
check("Concurrent approval submits one PMS write",handler.Writes==1&&applied.State=="Applied");
|
||||
var payload=JsonNode.Parse(handler.LastPayload!)!;
|
||||
check("OHIP mutation uses documented PUT wrapper and preserves prior notes",handler.LastMethod=="PUT"&&payload["reservations"]![0]!["hotelId"]!.GetValue<string>()=="TEST01"&&payload["reservations"]![0]!["comments"]!.AsArray().Count==2&&payload["reservations"]![0]!["comments"]![0]!["comment"]!["text"]!["value"]!.GetValue<string>()=="Original note");
|
||||
check("Completed PMS operation cannot be replayed",await Blocked(()=>work.Apply(applied,applied.Version,"owner",false,default))&&handler.Writes==1);
|
||||
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
var dates=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"StayDates","2030-10-15","2030-10-18",""));
|
||||
check("Date changes require availability and price acknowledgement",await Blocked(()=>work.Apply(dates,0,"owner",false,default))&&handler.Writes==1);
|
||||
handler.Current["lastModifyDateTime"]="2030-01-02T12:00:00";
|
||||
var stale=await work.Apply(dates,0,"owner",true,default);
|
||||
check("Fresh preflight blocks a changed PMS reservation",stale.State=="NotApplied"&&handler.Writes==1);
|
||||
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
dates=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"StayDates","2030-10-15","2030-10-18",""));
|
||||
var moved=await work.Apply(dates,0,"owner",true,default);
|
||||
check("Stay date update is verified by a fresh PMS read",moved.State=="Applied"&&moved.After?.Arrival=="2030-10-15"&&handler.Writes==2);
|
||||
check("Date update does not invent rates or use forced overlay",!handler.LastPayload!.Contains("roomRates")&&!handler.LastPayload.Contains("reservationNotification"));
|
||||
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
var uncertain=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Test uncertain result"));
|
||||
handler.TimeoutAfterWrite=true;uncertain=await work.Apply(uncertain,0,"owner",false,default);handler.TimeoutAfterWrite=false;int writes=handler.Writes;
|
||||
check("Uncertain PMS timeout is held without replay",uncertain.State=="NeedsReview"&&await Blocked(()=>work.Apply(uncertain,uncertain.Version,"owner",false,default))&&handler.Writes==writes);
|
||||
check("Uncertain PMS operation blocks a replacement proposal",await Blocked(()=>work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Replacement"))));
|
||||
var verified=await work.Verify(uncertain,uncertain.Version,default);
|
||||
check("PMS reconciliation reads state without issuing a write",verified.State=="Applied"&&handler.Writes==writes);
|
||||
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
var noEffect=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Missing note"));
|
||||
handler.IgnoreWrite=true;noEffect=await work.Apply(noEffect,0,"owner",false,default);handler.IgnoreWrite=false;
|
||||
check("HTTP success without intended PMS state requires review",noEffect.State=="NeedsReview");
|
||||
writes=handler.Writes;var held=await work.Verify(noEffect,noEffect.Version,default);check("Unconfirmed reconciliation stays held",held.State=="NeedsReview"&&handler.Writes==writes);
|
||||
config[prefix+"WritesEnabled"]="false";held=await work.Verify(held,held.Version,default);check("PMS reconciliation remains available with server writes disabled",held.State=="NeedsReview"&&handler.Writes==writes);config[prefix+"WritesEnabled"]="true";
|
||||
// A different reservation identity allows independent recovery tests.
|
||||
handler.ReservationId="RES456";handler.Current["reservationIdList"]![0]!["id"]="RES456";
|
||||
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
var interrupted=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Interrupted note"));
|
||||
interrupted.State="Applying";interrupted.UpdatedAt=DateTime.UtcNow.AddMinutes(-6);interrupted.Version=1;await store.Replace(hotel.Id,interrupted.Id,0,interrupted);
|
||||
var recovered=await work.Verify((await store.Get<PmsChange>(hotel.Id,interrupted.Id))!,1,default);
|
||||
check("Interrupted PMS change is only reconciled after restart",recovered.State=="NeedsReview"&&handler.Writes==writes);
|
||||
handler.ReservationId="RES789";handler.Current["reservationIdList"]![0]!["id"]="RES789";
|
||||
snapshot=await work.Lookup(hotel.Id,"CONF123",default);
|
||||
var cancelled=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Cancel me"));cancelled=await work.Cancel(cancelled,0);
|
||||
check("Unapplied proposal can be cancelled without PMS write",cancelled.State=="Cancelled"&&handler.Writes==writes);
|
||||
var expired=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Expired"));expired.ExpiresAt=DateTime.UtcNow.AddMinutes(-1);expired.Version=1;await store.Replace(hotel.Id,expired.Id,0,expired);
|
||||
check("Expired PMS approval is blocked",await Blocked(()=>work.Apply(expired,1,"owner",false,default)));await work.Cancel(expired,1);
|
||||
hotel.PmsUpdatesEnabled=false;hotel.Version=1;await store.Replace(hotel.Id,hotel.Id,0,hotel);
|
||||
var disabled=await work.Propose(hotel.Id,"owner",snapshot,new(snapshot.Id,"AddNote","","","Disabled"));check("Hotel PMS stop control blocks writes",await Blocked(()=>work.Apply(disabled,0,"owner",false,default))&&handler.Writes==writes);
|
||||
var profile=work.Profile(hotel.Id);config[prefix+"ClientSecret"]="rotated-fixture";
|
||||
check("OHIP credential rotation changes connection identity",work.Profile(hotel.Id).Revision!=profile.Revision);
|
||||
await client.Lookup(hotel.Id,work.Profile(hotel.Id),"CONF123",default);check("Rotated OHIP credentials do not reuse cached token",handler.TokenCalls==2);
|
||||
check("Missing hotel binding never uses another hotel credentials",OhipProfile.Read(config,Guid.NewGuid().ToString("N"))==null);
|
||||
config[prefix+"BaseUrl"]="http://127.0.0.1";check("OHIP configuration rejects insecure local origins",await Blocked(()=>{work.Profile(hotel.Id);return Task.CompletedTask;}));
|
||||
}
|
||||
sealed class Fixture:HttpMessageHandler
|
||||
{
|
||||
public string ReservationId="RES123";public int Writes,TokenCalls;public bool WrongDetailId,WrongProperty,Ambiguous,HasMore,TimeoutAfterWrite,IgnoreWrite;public string? LastPayload,LastMethod;
|
||||
public JsonNode Current=JsonNode.Parse("""
|
||||
{"reservationIdList":[{"id":"RES123","type":"Reservation"},{"id":"CONF123","type":"Confirmation"}],"hotelId":"TEST01","reservationStatus":"Reserved","lastModifyDateTime":"2030-01-01T12:00:00","roomStay":{"arrivalDate":"2030-10-12","departureDate":"2030-10-14","roomRates":[{"roomType":"KING"}],"total":{"amountAfterTax":240,"currencyCode":"GBP"}},"reservationGuests":[{"primary":true,"profileInfo":{"profile":{"customer":{"personName":[{"givenName":"Alex","surname":"Guest","nameType":"Primary"}]}}}}],"comments":[{"id":"NOTE1","type":"Comment","comment":{"text":{"value":"Original note"},"type":"RESERVATION","notificationLocation":"GEN","internal":true}}]}
|
||||
""")!;
|
||||
static HttpResponseMessage Json(object value)=>new(HttpStatusCode.OK){Content=new StringContent(JsonSerializer.Serialize(value),Encoding.UTF8,"application/json")};
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct)
|
||||
{
|
||||
if(request.RequestUri!.Host!="ohip.example.invalid")throw new Exception("Fixture cannot access real OHIP");
|
||||
if(request.RequestUri.AbsolutePath=="/oauth/v1/tokens"){TokenCalls++;return Json(new{access_token="fixture-token",expires_in=3600});}
|
||||
if(!request.Headers.TryGetValues("x-hotelid",out var codes)||codes.Single()!="TEST01")throw new Exception("Wrong hotel header");
|
||||
if(request.Method==HttpMethod.Put)
|
||||
{
|
||||
Writes++;LastMethod=request.Method.Method;LastPayload=await request.Content!.ReadAsStringAsync(ct);
|
||||
if(!IgnoreWrite)
|
||||
{
|
||||
var update=JsonNode.Parse(LastPayload)!["reservations"]![0]!;
|
||||
if(update["comments"] is {} comments)Current["comments"]=comments.DeepClone();
|
||||
if(update["roomStay"] is {} stay){Current["roomStay"]!["arrivalDate"]=stay["arrivalDate"]!.DeepClone();Current["roomStay"]!["departureDate"]=stay["departureDate"]!.DeepClone();}
|
||||
Current["lastModifyDateTime"]="2030-01-03T12:00:"+Writes.ToString("00");
|
||||
}
|
||||
if(TimeoutAfterWrite)throw new TaskCanceledException("Fixture timeout after provider committed update");
|
||||
return Json(new{});
|
||||
}
|
||||
var row=Current.DeepClone();
|
||||
if(WrongDetailId&&request.RequestUri.AbsolutePath.EndsWith('/'+ReservationId))row["reservationIdList"]![0]!["id"]="WRONG";
|
||||
if(WrongProperty)row["hotelId"]="OTHER";
|
||||
var rows=new JsonArray(row);if(Ambiguous)rows.Add(row.DeepClone());
|
||||
return Json(new JsonObject{["reservations"]=new JsonObject{["reservation"]=rows,["hasMore"]=HasMore}});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ await store.Initialize();
|
||||
try
|
||||
{
|
||||
await ReplyTests.Run(Check, store);
|
||||
await PmsTests.Run(Check, store);
|
||||
var a = new Hotel { Name = "Hotel A" }; a.HotelId = a.Id;
|
||||
var b = new Hotel { Name = "Hotel B" }; b.HotelId = b.Id;
|
||||
await store.Insert(a); await store.Insert(b);
|
||||
@ -80,6 +81,11 @@ try
|
||||
Check("Cross-hotel AI generation is blocked", (await two.PostAsJsonAsync($"/api/conversations/{id}/generate",new {version=1})).StatusCode==HttpStatusCode.NotFound);
|
||||
Check("Preview cannot send real mail", (await one.PostAsJsonAsync($"/api/conversations/{id}/send",new {version=1,recipient="guest@example.invalid"})).StatusCode==HttpStatusCode.BadRequest);
|
||||
Check("Cross-hotel delivery status is blocked", (await two.GetAsync($"/api/conversations/{id}")).StatusCode==HttpStatusCode.NotFound);
|
||||
Check("PMS status exposes no credentials", !(await one.GetStringAsync("/api/pms/status")).Contains("clientSecret"));
|
||||
Check("Preview cannot access real PMS lookup", (await one.PostAsJsonAsync("/api/pms/lookup",new{confirmation="CONF123"})).StatusCode==HttpStatusCode.BadRequest);
|
||||
Check("Preview cannot enable PMS updates", (await one.PutAsJsonAsync("/api/pms/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest);
|
||||
Check("Unknown PMS snapshot cannot be proposed", (await two.PostAsJsonAsync("/api/pms/changes",new{snapshotId="foreign",kind="AddNote",arrival="",departure="",note="test"})).StatusCode==HttpStatusCode.NotFound);
|
||||
Check("Unknown PMS operation cannot be applied", (await two.PostAsJsonAsync("/api/pms/changes/foreign/apply",new{version=0,availabilityAndPriceChecked=true})).StatusCode==HttpStatusCode.NotFound);
|
||||
var cookie=(await one.GetAsync("/api/session")).Headers;
|
||||
Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true);
|
||||
await one.PostAsJsonAsync("/api/auth/logout",new {});
|
||||
|
||||
28
web/src/PmsPage.tsx
Normal file
28
web/src/PmsPage.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type Hotel } from './api';
|
||||
type Snapshot={id:string;reservationId:string;confirmation:string;guestName:string;arrival:string;departure:string;status:string;roomType:string;total:string;fetchedAt:string};
|
||||
type Change={id:string;reservationId:string;kind:string;arrival:string;departure:string;note:string;state:string;detail:string;version:number;updatedAt:string;expiresAt:string;before:Snapshot;after:Snapshot|null};
|
||||
type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean};
|
||||
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
const [connection,setConnection]=useState<Connection|null>(null),[changes,setChanges]=useState<Change[]>([]),[confirmation,setConfirmation]=useState(''),[snapshot,setSnapshot]=useState<Snapshot|null>(null);
|
||||
const [kind,setKind]=useState('AddNote'),[arrival,setArrival]=useState(''),[departure,setDeparture]=useState(''),[note,setNote]=useState(''),[selected,setSelected]=useState<string|null>(null),[checked,setChecked]=useState(false);
|
||||
async function refresh(){const [status,history]=await Promise.all([api<Connection>('/pms/status'),api<Change[]>('/pms/changes')]);setConnection(status);setChanges(history);}
|
||||
useEffect(()=>{run(refresh);},[hotel.id]);
|
||||
const current=changes.find(c=>c.id===selected);
|
||||
function update(change:Change){setChanges(old=>[change,...old.filter(c=>c.id!==change.id)]);setSelected(change.id);setChecked(false);}
|
||||
async function lookup(e:React.FormEvent){e.preventDefault();await run(async()=>{const found=await api<Snapshot>('/pms/lookup','POST',{confirmation:confirmation.trim()});setSnapshot(found);setArrival(found.arrival);setDeparture(found.departure);setSelected(null);setNote('');setChecked(false);});}
|
||||
async function propose(e:React.FormEvent){e.preventDefault();if(!snapshot)return;await run(async()=>update(await api<Change>('/pms/changes','POST',{snapshotId:snapshot.id,kind,arrival,departure,note})));}
|
||||
async function action(name:string){if(!current)return;await run(async()=>{
|
||||
if(name==='apply'&&!window.confirm(`Apply this change to OHIP reservation ${current.before.confirmation} for ${current.before.guestName||'the displayed guest'}?\n\n${current.kind==='StayDates'?`${current.before.arrival} – ${current.before.departure} → ${current.arrival} – ${current.departure}`:current.note}\n\nThis changes the live PMS selected by your administrator.`))return;
|
||||
update(await api<Change>(`/pms/changes/${current.id}/${name}`,'POST',{version:current.version,availabilityAndPriceChecked:checked}));
|
||||
});}
|
||||
return <div className="page pms-page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Booking details, close at hand</span><h1>Reservations</h1><p>Look up a booking and review changes before applying them to your PMS.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh status</button></div>
|
||||
<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>
|
||||
{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>
|
||||
{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>;
|
||||
}
|
||||
function Reservation({value}:{value:Snapshot}){return <dl className="pms-reservation"><div><dt>Confirmation</dt><dd>{value.confirmation}</dd></div><div><dt>Guest</dt><dd>{value.guestName||'Not returned by PMS — verify guest identity there'}</dd></div><div><dt>Stay</dt><dd>{value.arrival} → {value.departure}</dd></div><div><dt>Room type</dt><dd>{value.roomType||'Not returned'}</dd></div><div><dt>Status</dt><dd>{value.status||'Not returned'}</dd></div><div><dt>Recorded total</dt><dd>{value.total||'Not returned — check in PMS'}</dd></div></dl>;}
|
||||
@ -1,6 +1,6 @@
|
||||
export type User = { id: string; name: string; role: string; hotelId: string };
|
||||
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 };
|
||||
export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: 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; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null };
|
||||
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 };
|
||||
|
||||
@ -3,6 +3,7 @@ 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, Building2, ChevronRight } from 'lucide-react';
|
||||
import { api, session, type Session, type Hotel, type Conversation, type Knowledge, type Activity, type Mailboxes } from './api';
|
||||
import './style.css';
|
||||
import { PmsPage } from './PmsPage';
|
||||
import { ReplyActions, ReplyControls } from './ReplyActions';
|
||||
|
||||
const labels: Record<string,string> = { NeedsAttention: 'Needs attention', DraftReady: 'Draft ready', Completed: 'Completed' };
|
||||
@ -36,13 +37,13 @@ function App() {
|
||||
<a href="/inbox" aria-label="GuestOps inbox" className="brand" onClick={e=>{e.preventDefault();go('/inbox');}}><span className="brand-mark">g</span><span>guestops<span className="brand-dot">.</span></span></a>
|
||||
<div className="hotel-switch"><span className="hotel-icon"><Building2 size={19}/></span><div><strong>{hotel?.name||'Your hotel'}</strong><small>Hotel workspace</small></div></div>
|
||||
<div className="nav-label">WORKSPACE</div>
|
||||
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings}].map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/reservations',label:'Reservations',icon:Building2},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings}].map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||
<div className="sidebar-bottom"><div className="mode-card"><ShieldCheck size={20}/><strong>You're in control</strong><p>Replies stay as drafts until your team reviews them.</p></div><div className="profile"><span className="avatar profile-avatar">{initials(auth.user.name)}</span><div><strong>{auth.user.name}</strong><small>{auth.user.role==='Owner'?'Hotel owner':'Team member'}</small></div><button className="icon-button" onClick={logout} disabled={busy} aria-label="Sign out"><LogOut size={18}/></button></div></div>
|
||||
</aside>
|
||||
<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':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{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':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{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>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>: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==='/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 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==='/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==='/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 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>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@ -9,3 +9,5 @@
|
||||
@media(prefers-reduced-motion:no-preference){.button,.nav-item,.message-card{transition:background .15s ease}.toast{animation:appear .2s ease}@keyframes appear{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}}
|
||||
|
||||
.reply-actions{padding:18px 0;border-bottom:1px solid var(--line);overflow-wrap:anywhere}.reply-actions details{padding:12px;background:#f3f5ef;border-radius:10px}.reply-actions details p{white-space:pre-wrap}.reply-actions .form-actions{flex-wrap:wrap;gap:8px}.reply-actions p{line-height:1.6}
|
||||
|
||||
.pms-columns{display:grid;grid-template-columns:1fr 1fr;gap:20px}.pms-page h2{font-size:20px;margin-bottom:18px}.pms-page form{margin-top:18px}.pms-reservation{display:grid;gap:10px;margin:24px 0;padding:18px;background:#f3f6ee;border-radius:10px}.pms-reservation>div{display:grid;grid-template-columns:120px 1fr;gap:12px}.pms-reservation dt{color:#71816b;font-size:13px}.pms-reservation dd{margin:0;overflow-wrap:anywhere;font-size:14px}.pms-history{display:grid;gap:10px;max-height:480px;overflow:auto}.pms-history-item{text-align:left;background:#f7f9f4;border:1px solid var(--border);border-radius:8px;padding:14px;color:#375344}.pms-history-item.selected{border-color:#6c8a54;background:#edf3e7}.pms-history-item span{display:block;font-size:12px;line-height:1.7;margin-top:6px}.pms-review .staff-note{white-space:pre-wrap;overflow-wrap:anywhere}.pms-review .checkbox-label{align-items:flex-start;line-height:1.7}.pms-review .checkbox-label input{flex-shrink:0}.pms-review .form-actions{flex-wrap:wrap}.pms-page .small{overflow-wrap:anywhere}@media(max-width:1100px){.pms-columns{grid-template-columns:1fr}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user