diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..143accf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/bin +**/obj +**/node_modules +**/dist +.git +.env +.env.* +**/keys +tests +*.tar* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1e8c3a --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Generate separate 32-byte hex values with: openssl rand -hex 32 +# Keep .env private (chmod 600). Never commit real values. +MONGO_ROOT_PASSWORD= +MONGO_APP_PASSWORD= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# CI produces image archives. Set these to the loaded, reviewed commit tags. +GUESTOPS_API_IMAGE=guestops-api:local +GUESTOPS_WORKER_IMAGE=guestops-worker:local diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..61fbcc6 --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,53 @@ +name: Build and verify web migration +on: + push: + branches: [main, 'codex/**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + verify: + runs-on: ubuntu-latest + services: + mongo: + image: mongo:8.0 + ports: ['27017:27017'] + options: >- + --health-cmd "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'" + --health-interval 10s --health-timeout 5s --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: { dotnet-version: '10.0.x' } + - uses: actions/setup-node@v4 + with: { node-version: '22', cache: npm, cache-dependency-path: web/package-lock.json } + - name: Build services + run: dotnet build src/GuestOps.Worker/GuestOps.Worker.csproj -c Release + - name: Build interface + working-directory: web + run: npm ci && npm run build + - name: Start isolated preview API + run: | + ASPNETCORE_ENVIRONMENT=Development Preview=true dotnet src/GuestOps.Api/bin/Release/net10.0/GuestOps.Api.dll --urls http://127.0.0.1:5180 > /tmp/guestops-api.log 2>&1 & + for i in $(seq 1 30); do curl -fsS http://127.0.0.1:5180/health && exit 0; sleep 1; done + cat /tmp/guestops-api.log + exit 1 + - name: Verify MongoDB and HTTP boundaries + env: + MONGO_TEST_URI: mongodb://127.0.0.1:27017 + TEST_API_URL: http://127.0.0.1:5180 + run: dotnet run --project tests/GuestOps.Tests/GuestOps.Tests.csproj -c Release + - name: Build Linux images + run: | + docker build --target api -t guestops-api:${{ github.sha }} . + docker build --target worker -t guestops-worker:${{ github.sha }} . + - name: Package reviewed images + if: github.event_name != 'pull_request' + run: docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip > guestops-images.tar.gz + - uses: actions/upload-artifact@v4 + if: github.event_name != 'pull_request' + with: + name: guestops-linux-${{ github.run_number }} + path: guestops-images.tar.gz + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..052ba79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +**/bin/ +**/obj/ +**/node_modules/ +**/dist/ +**/*.tsbuildinfo +.env +.env.* +!.env.example +**/appsettings.Local.json +**/keys/ +*.log +*.tar +*.tar.gz +.DS_Store diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..e266582 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1 @@ +net10.0enableenabletrue diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8714577 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:22-bookworm-slim AS frontend +WORKDIR /build/web +COPY web/package*.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend +WORKDIR /build +COPY Directory.Build.props ./ +COPY src/ ./src/ +RUN dotnet publish src/GuestOps.Api/GuestOps.Api.csproj -c Release -o /publish/api +RUN dotnet publish src/GuestOps.Worker/GuestOps.Worker.csproj -c Release -o /publish/worker + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api +WORKDIR /app +RUN mkdir -p /var/lib/guestops/keys && chown -R app:app /var/lib/guestops && chmod 700 /var/lib/guestops/keys +COPY --from=backend /publish/api ./ +COPY --from=frontend /build/web/dist ./wwwroot/ +ENV ASPNETCORE_URLS=http://+:8080 ASPNETCORE_ENVIRONMENT=Production Keys__Path=/var/lib/guestops/keys +USER app +EXPOSE 8080 +ENTRYPOINT ["dotnet", "GuestOps.Api.dll"] + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS worker +WORKDIR /app +RUN mkdir -p /var/lib/guestops/keys && chown -R app:app /var/lib/guestops && chmod 700 /var/lib/guestops/keys +COPY --from=backend /publish/worker ./ +ENV DOTNET_ENVIRONMENT=Production Keys__Path=/var/lib/guestops/keys +USER app +ENTRYPOINT ["dotnet", "GuestOps.Worker.dll"] diff --git a/README.md b/README.md index 7bb853a..7681dc7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,59 @@ # GuestOps Web -Linux-hosted hotel email workspace. The web migration is being developed separately from the GuestOps desktop application. +A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This is the first migration milestone, not a production-complete replacement.** -Planned stack: React, ASP.NET Core, MongoDB, and a background mailbox worker. Initial rollout is draft-only. Deployment target: Debian 12. +## Working in this milestone + +- Responsive React inbox, message search and status filters, editable saved drafts, approved hotel answers, activity history and hotel settings. +- ASP.NET Core authentication using protected HttpOnly cookies, password hashing, CSRF validation, login rate limiting and server-derived hotel membership. Owner-only settings and knowledge editing. +- MongoDB storage with tenant-scoped operations, unique mailbox/message indexes, optimistic concurrency, single-use OAuth state and expiring worker leases. +- Google OAuth connection and a separate read-only Gmail 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. +- Docker image builds, private MongoDB configuration and an Nginx HTTPS example for the Debian sandbox. + +## Explicit limits + +No emails are sent by this milestone. Drafts are written by staff or assembled from approved hotel answers. AI-generated FAQ replies, 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. + +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. + +## Local development + +Requires .NET 10 SDK and Node.js 22. In the repository root: + +```sh +dotnet build src/GuestOps.Worker/GuestOps.Worker.csproj +cd web +npm ci +npm run dev +``` + +In a second terminal, start the isolated preview API: + +```sh +# Linux/macOS shell +ASPNETCORE_ENVIRONMENT=Development Preview=true dotnet run --project src/GuestOps.Api --urls http://127.0.0.1:5180 +``` + +PowerShell equivalent: + +```powershell +$env:ASPNETCORE_ENVIRONMENT='Development' +$env:Preview='true' +dotnet run --project src/GuestOps.Api --urls http://127.0.0.1:5180 +``` + +Open http://127.0.0.1:5173 and select **Open preview workspace**. Each preview login creates its own sample hotel. This mode uses temporary memory storage, does not connect real mailboxes, and cannot start in Production. Do not use the development server as a public deployment. + +For MongoDB-backed operation, disable Preview and set `Mongo__ConnectionString`, `Mongo__Database`, and a persistent private `Keys__Path`. See [deployment](docs/deployment.md). + +## Verification + +```sh +dotnet run --project tests/GuestOps.Tests +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 [migration status](docs/migration.md) and [deployment guide](docs/deployment.md). diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..ce6c37e --- /dev/null +++ b/compose.yml @@ -0,0 +1,61 @@ +name: guestops +x-app-env: &app-env + Mongo__ConnectionString: mongodb://guestops:${MONGO_APP_PASSWORD:?Set MONGO_APP_PASSWORD}@mongo:27017/guestops?authSource=guestops + Mongo__Database: guestops + Keys__Path: /var/lib/guestops/keys + PublicUrl: https://sandbox-guestops.futuresens.co.uk + Google__ClientId: ${GOOGLE_CLIENT_ID:-} + Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-} + Logging__LogLevel__Default: Warning + Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Warning +x-logging: &logging + driver: json-file + options: { max-size: "10m", max-file: "3" } +services: + api: + image: ${GUESTOPS_API_IMAGE:-guestops-api:local} + build: { context: ., target: api } + restart: unless-stopped + ports: ["127.0.0.1:8080:8080"] + environment: + <<: *app-env + ASPNETCORE_ENVIRONMENT: Production + AllowedHosts: sandbox-guestops.futuresens.co.uk;localhost + volumes: ["app-keys:/var/lib/guestops/keys"] + depends_on: + mongo: { condition: service_healthy } + logging: *logging + mem_limit: 1g + security_opt: ["no-new-privileges:true"] + worker: + image: ${GUESTOPS_WORKER_IMAGE:-guestops-worker:local} + build: { context: ., target: worker } + restart: unless-stopped + environment: *app-env + volumes: ["app-keys:/var/lib/guestops/keys"] + depends_on: + mongo: { condition: service_healthy } + logging: *logging + mem_limit: 768m + security_opt: ["no-new-privileges:true"] + mongo: + image: mongo:8.0 + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?Set MONGO_ROOT_PASSWORD} + MONGO_APP_PASSWORD: ${MONGO_APP_PASSWORD:?Set MONGO_APP_PASSWORD} + volumes: + - mongo-data:/data/db + - ./deploy/mongo-init.js:/docker-entrypoint-initdb.d/guestops.js:ro + command: ["mongod", "--wiredTigerCacheSizeGB", "1"] + healthcheck: + test: ["CMD-SHELL", "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok' | grep 1"] + interval: 10s + timeout: 5s + retries: 12 + logging: *logging + mem_limit: 2500m +volumes: + mongo-data: + app-keys: diff --git a/deploy/mongo-init.js b/deploy/mongo-init.js new file mode 100644 index 0000000..090549d --- /dev/null +++ b/deploy/mongo-init.js @@ -0,0 +1,2 @@ +const guestops = db.getSiblingDB('guestops'); +guestops.createUser({user: 'guestops', pwd: process.env.MONGO_APP_PASSWORD, roles: [{role: 'readWrite', db: 'guestops'}]}); diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..b77b541 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,31 @@ +# Install within the existing Nginx configuration. Obtain the certificate first. +server { + listen 80; + server_name sandbox-guestops.futuresens.co.uk; + location /.well-known/acme-challenge/ { root /var/www/letsencrypt; } + location / { return 301 https://$host$request_uri; } +} +server { + listen 443 ssl; + server_name sandbox-guestops.futuresens.co.uk; + ssl_certificate /etc/letsencrypt/live/sandbox-guestops.futuresens.co.uk/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/sandbox-guestops.futuresens.co.uk/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + add_header Strict-Transport-Security "max-age=31536000" always; + client_max_body_size 128k; + # OAuth authorization codes and state must not appear in access logs. + location = /api/integrations/google/callback { + access_log off; + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + } + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_read_timeout 60s; + } +} diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..ebcc7c0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,73 @@ +# Debian sandbox deployment + +Target: `https://sandbox-guestops.futuresens.co.uk`. These are reviewable deployment instructions; creating these files does not deploy or change the server. + +## 1. Prepare the host + +Check the existing Nginx, Docker, MongoDB and firewall configuration before installation. Preserve existing services. The provided Compose file creates a dedicated MongoDB instance for GuestOps with no host port; if MongoDB already exists on the host, use a reviewed private connection and application-scoped user instead of starting a conflicting instance. + +Install Docker Engine/Compose and Nginx using their official Debian instructions. Keep SSH access unchanged. Only HTTPS/HTTP for this hostname need public access; port 8080 is loopback-only and MongoDB has no published port. + +Clone the private repository using an authorized GitHub account. Place the checkout in a dedicated application directory. Copy `.env.example` to `.env`, set permissions to 600, and fill two different MongoDB passwords generated with `openssl rand -hex 32`. Use hex values so they are safe in the MongoDB URI. Store real values only on the server or in its secret-management system. + +The MongoDB initialization script runs only on a new volume. Changing `.env` later does not rotate existing database users. Rotate those credentials through MongoDB administration and update application configuration together. + +## 2. Load reviewed application images + +CI builds API and worker images and packages them in a `guestops-linux-*` artifact. Download the successful artifact for the desired commit and transfer it to the sandbox through your normal authorized deployment process. + +```sh +docker load -i guestops-images.tar.gz +``` + +Set `GUESTOPS_API_IMAGE=guestops-api:` and `GUESTOPS_WORKER_IMAGE=guestops-worker:` in `.env` using that exact build's SHA. Then run: + +```sh +docker compose config --quiet +docker compose up -d --no-build +curl --fail http://127.0.0.1:8080/health +``` + +Do not run `docker compose config` without `--quiet` in shared logs: expanded configuration contains secrets. Local image builds are available with Compose for development, but CI builds avoid consuming sandbox resources. + +## 3. Provision the first hotel owner + +The API has an administrative bootstrap command. It creates one hotel and one owner, refuses an existing email address, and does not reset passwords. Run it with environment variables passed from a private terminal; do not put the password directly in shell history or a repository file. + +```sh +read -r -p 'Owner email: ' BOOTSTRAP_EMAIL +read -r -p 'Hotel name: ' BOOTSTRAP_HOTEL +read -r -s -p 'Owner password (14+ characters): ' BOOTSTRAP_PASSWORD +printf '\n' +export BOOTSTRAP_EMAIL BOOTSTRAP_HOTEL BOOTSTRAP_PASSWORD +docker compose run --rm --no-deps -e BOOTSTRAP_EMAIL -e BOOTSTRAP_HOTEL -e BOOTSTRAP_PASSWORD api --bootstrap +unset BOOTSTRAP_EMAIL BOOTSTRAP_HOTEL BOOTSTRAP_PASSWORD +``` + +There is no development/demo account in production. Staff invitation and password recovery UI are follow-on work; do not treat this as a public self-service service yet. + +## 4. Configure HTTPS + +Verify the hostname's DNS resolves to this server. Obtain a valid certificate for it using your existing ACME/Certbot process. The Nginx example expects a Let's Encrypt certificate. For first issuance, configure the port-80 ACME location before enabling the port-443 block; do not point Nginx at missing certificate files. + +Merge `deploy/nginx.conf` into the existing host configuration, check with `nginx -t`, then reload Nginx. Confirm HTTPS serves the login page. API cookies are always Secure outside preview mode; logging in through plain HTTP is intentionally unsupported. + +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. + +## 5. Configure Google + +Create or select your Google Cloud project, enable Gmail API, and configure a Web application OAuth client. Register this exact redirect URI: + +`https://sandbox-guestops.futuresens.co.uk/api/integrations/google/callback` + +Configure `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the server's `.env` and recreate the API/worker services. Sign in as hotel owner, open Settings and connect a dedicated test mailbox. The only requested Gmail scope is `gmail.readonly`. + +OAuth requests expire after ten minutes, are bound to the signed-in user and hotel, and can be consumed once. Refresh tokens are protected with ASP.NET Data Protection. API and worker share the private persistent key volume; back it up securely with the database. Losing it prevents existing mailbox tokens and sessions from being decrypted. Filesystem protection for the key volume is required; it is separate from MongoDB and must not be publicly served or committed. + +A multi-hotel production launch using Gmail restricted scopes requires planning for Google's verification/security-assessment requirements. This implementation does not bypass those requirements. See [Google restricted-scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/restricted-scope-verification). + +## 6. Acceptance and rollback + +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. Review the activity log and Google account used by the connection. + +Keep reviewed image tags 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. Establish retention, off-server backup and a restore drill before importing real guest data. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..6675a28 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,31 @@ +# Migration status + +Source: `wolf-demon/GuestOps`, hardened desktop commit `18b983bf402ecdded6430fd40bc4d3320587595a` on `codex/audit-safety-fixes`. The desktop repository is unchanged by this web migration. + +## Milestone 1: inbox and persistence + +The existing Windows business model, booking preflight checks, body cleaner, extraction parser and secret redactor are copied into `GuestOps.Core`, which targets .NET 10 without WinForms. They retain the original namespaces to make later adapter migration reviewable. + +`GuestOps.Api` owns the web endpoints and MongoDB infrastructure. `GuestOps.Worker` currently references this project to share the storage/Google adapter without duplicating it. A later separation into an Infrastructure project can occur when PMS adapters are ported; it is not necessary for the present read-only worker. + +Local JSON stores and process mutexes are not reused in production. MongoDB enforces uniqueness for message import and mailbox ownership; version predicates prevent lost updates. Every application data read/update takes a server-derived hotel ID. Only login lookup and the trusted worker perform narrowly defined global queries. + +The UI is an operational inbox rather than a port of the desktop booking grid. Real installations start empty. The sample hotel exists only in explicitly enabled Development preview mode. The preview banner remains visible at compact widths. + +Authentication uses ASP.NET cookie protection and its password hasher. Sessions expire after eight hours and validate the user's active status and role on each request. Owner provisioning is an administrator CLI operation; no staff invitation or self-service recovery flow is claimed yet. A staff-facing pilot should not expand beyond administrator-supported accounts until those flows are added. + +Gmail permissions are read-only. The worker fetches plain-text bodies and skips automated/list/bounce messages. It does not fetch attachments, mark messages read, delete them, send mail, or call a PMS. Initial import is seven days, 25 messages per worker cycle. Unfinished pages retain their checkpoint, and overlap between synchronization windows is deduplicated. Invalid provider pagination tokens currently require operator reconnection/reset of the mailbox checkpoint; there is no full history-repair UI yet. + +## Next 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. Add AI draft generation from approved hotel knowledge, evidence display, evaluation cases and explicit staff escalation. Approve the data-processing arrangements for the selected AI provider. +4. Implement a tenant-scoped durable send outbox, operator reconciliation and guarded FAQ auto-replies. Preserve the desktop 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. + +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. diff --git a/src/GuestOps.Api/Demo.cs b/src/GuestOps.Api/Demo.cs new file mode 100644 index 0000000..049eb87 --- /dev/null +++ b/src/GuestOps.Api/Demo.cs @@ -0,0 +1,22 @@ +namespace GuestOps.Web; +public static class Demo +{ + public static async Task Seed(IStore store) + { + var hotel = new Hotel { Name = "The Willow House", Signature = "Warm regards,\nThe Willow House team" }; hotel.HotelId = hotel.Id; + var user = new StaffUser { HotelId = hotel.Id, Name = "Alex Morgan", Email = hotel.Id + "@example.invalid" }; + await store.Insert(hotel); await store.Insert(user); + var questions = new[] { + ("Emma Wilson", "A little question before our weekend stay", "Hello! We're looking forward to staying with you on Friday. Is there somewhere to park our car, and do we need to book a space?\n\nMany thanks,\nEmma", "Parking", "Hello Emma,\n\nWe're looking forward to welcoming you on Friday. Complimentary parking is available in our courtyard, subject to availability. There is no need to reserve a space.\n\nWarm regards,\nThe Willow House team"), + ("James & Sophie", "Arriving a little earlier on Saturday", "Hi there, our train gets in at 12:30. Would it be possible to leave our bags with you before check-in? Thank you!", "Arrival", ""), + ("Oliver Brooks", "Changing the dates of our booking", "Our plans have changed. Could we move reservation WH-2481 to the following weekend? Please let me know whether there is a charge.", "Booking change", ""), + ("Charlotte Reed", "Breakfast options for our stay", "Good morning, do you offer vegetarian breakfast options? My partner also has a nut allergy.", "Dietary request", ""), + ("Amelia Patel", "Thank you for a lovely stay", "We had a wonderful weekend. Please pass on our thanks to the team!", "General", "") + }; + for (int i = 0; i < questions.Length; i++) { var q = questions[i]; await store.Insert(new Conversation { HotelId = hotel.Id, ProviderMessageId = Guid.NewGuid().ToString("N"), From = q.Item1 + " ", Subject = q.Item2, Body = q.Item3, Category = q.Item4, Draft = q.Item5, Status = i == 0 ? "DraftReady" : i == 4 ? "Completed" : "NeedsAttention", ReceivedAt = DateTime.UtcNow.AddMinutes(-i * 28 - 12), Note = i == 2 ? "A booking change needs a member of your team." : i == 3 ? "Check dietary requirements with the hotel team." : "" }); } + 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" }); + return user; + } +} diff --git a/src/GuestOps.Api/GoogleMailbox.cs b/src/GuestOps.Api/GoogleMailbox.cs new file mode 100644 index 0000000..ad5a038 --- /dev/null +++ b/src/GuestOps.Api/GoogleMailbox.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +namespace GuestOps.Web; + +public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore store, IDataProtectionProvider protection) +{ + private readonly IDataProtector protector = protection.CreateProtector("GoogleMailbox.refresh.v1"); + private string ClientId => config["Google:ClientId"] ?? ""; + private string ClientSecret => config["Google:ClientSecret"] ?? ""; + private string Callback => (config["PublicUrl"] ?? "https://sandbox-guestops.futuresens.co.uk").TrimEnd('/') + "/api/integrations/google/callback"; + public bool Configured => ClientId.Length > 0 && ClientSecret.Length > 0; + public string AuthorizationUrl(string state) => "https://accounts.google.com/o/oauth2/v2/auth?" + string.Join("&", new Dictionary { + ["client_id"] = ClientId, ["redirect_uri"] = Callback, ["response_type"] = "code", ["scope"] = "https://www.googleapis.com/auth/gmail.readonly", ["access_type"] = "offline", ["prompt"] = "consent", ["state"] = state + }.Select(x => Uri.EscapeDataString(x.Key) + "=" + Uri.EscapeDataString(x.Value))); + async Task Token(Dictionary data, CancellationToken ct = default) + { + data["client_id"] = ClientId; data["client_secret"] = ClientSecret; + using var response = await http.PostAsync("https://oauth2.googleapis.com/token", new FormUrlEncodedContent(data), ct); + response.EnsureSuccessStatusCode(); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement.Clone(); + } + async Task Read(string path, string token, CancellationToken ct = default) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "https://gmail.googleapis.com/gmail/v1/users/me/" + path); + request.Headers.Authorization = new("Bearer", token); + using var response = await http.SendAsync(request, ct); response.EnsureSuccessStatusCode(); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement.Clone(); + } + public async Task Connect(string hotel, string code) + { + if (string.IsNullOrWhiteSpace(code)) throw new InvalidOperationException("No authorization code."); + var tokens = await Token(new() { ["code"] = code, ["redirect_uri"] = Callback, ["grant_type"] = "authorization_code" }); + var profile = await Read("profile", tokens.GetProperty("access_token").GetString()!); + var email = profile.GetProperty("emailAddress").GetString()!.ToLowerInvariant(); + var prior = (await store.List(hotel)).SingleOrDefault(x => x.Email == email); + var mailbox = prior ?? new Mailbox { HotelId = hotel, Email = email }; + // No token reuse across hotels. Mongo's unique mailbox-email index prevents + // accidental connection of one shared mailbox to two hotel workspaces. + mailbox.ProtectedRefreshToken = protector.Protect(tokens.GetProperty("refresh_token").GetString()!); + mailbox.Status = "Connected"; mailbox.SyncError = ""; + await store.SaveMailbox(mailbox); + } + public async Task Sync(Mailbox mailbox, CancellationToken ct) + { + var tokens = await Token(new() { ["refresh_token"] = protector.Unprotect(mailbox.ProtectedRefreshToken), ["grant_type"] = "refresh_token" }, ct); + var token = tokens.GetProperty("access_token").GetString()!; + var after = new DateTimeOffset(mailbox.WindowStart).ToUnixTimeSeconds(); + var before = new DateTimeOffset(mailbox.WindowEnd).ToUnixTimeSeconds(); + var query = Uri.EscapeDataString($"in:inbox after:{after} before:{before}"); + var path = "messages?maxResults=25&q=" + query + (mailbox.PageToken.Length > 0 ? "&pageToken=" + Uri.EscapeDataString(mailbox.PageToken) : ""); + var page = await Read(path, token, ct); + if (page.TryGetProperty("messages", out var items)) foreach (var item in items.EnumerateArray()) + { + var id = item.GetProperty("id").GetString()!; + var message = await Read("messages/" + Uri.EscapeDataString(id) + "?format=full", token, ct); + var payload = message.GetProperty("payload"); + string Header(string name) => payload.GetProperty("headers").EnumerateArray().Where(x => string.Equals(x.GetProperty("name").GetString(), name, StringComparison.OrdinalIgnoreCase)).Select(x => x.GetProperty("value").GetString()).FirstOrDefault() ?? ""; + var auto = Header("Auto-Submitted"); + if ((auto.Length > 0 && auto != "no") || Header("List-Id").Length > 0 || Header("Return-Path").Trim() == "<>") continue; + var body = PlainText(payload); + var row = new Conversation { HotelId = mailbox.HotelId, MailboxId = mailbox.Id, ProviderMessageId = id, ProviderThreadId = message.GetProperty("threadId").GetString()!, From = Header("From"), Subject = Header("Subject"), Body = body.Length > 0 ? body[..Math.Min(body.Length, 30000)] : "This message has no plain-text body. Open it in Gmail to read it.", ReceivedAt = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(message.GetProperty("internalDate").GetString()!)).UtcDateTime }; + await store.Import(row); // deduplicated before advancing the page checkpoint + } + mailbox.PageToken = page.TryGetProperty("nextPageToken", out var next) ? next.GetString()! : ""; + if (mailbox.PageToken.Length == 0) { mailbox.WindowStart = mailbox.WindowEnd.AddMinutes(-5); mailbox.WindowEnd = DateTime.UtcNow; } + mailbox.LastSyncAt = DateTime.UtcNow; mailbox.SyncError = ""; + await store.SaveSync(mailbox); + } + static string PlainText(JsonElement payload) + { + if (payload.TryGetProperty("mimeType", out var mime) && mime.GetString() == "text/plain" && payload.TryGetProperty("body", out var body) && body.TryGetProperty("data", out var data)) + { + var raw = data.GetString()!.Replace('-', '+').Replace('_', '/'); raw = raw.PadRight((raw.Length + 3) / 4 * 4, '='); + return Encoding.UTF8.GetString(Convert.FromBase64String(raw)); + } + return payload.TryGetProperty("parts", out var parts) ? string.Join("\n", parts.EnumerateArray().Select(PlainText).Where(x => x.Length > 0)) : ""; + } +} diff --git a/src/GuestOps.Api/GuestOps.Api.csproj b/src/GuestOps.Api/GuestOps.Api.csproj new file mode 100644 index 0000000..9478959 --- /dev/null +++ b/src/GuestOps.Api/GuestOps.Api.csproj @@ -0,0 +1 @@ + diff --git a/src/GuestOps.Api/Models.cs b/src/GuestOps.Api/Models.cs new file mode 100644 index 0000000..35ed7da --- /dev/null +++ b/src/GuestOps.Api/Models.cs @@ -0,0 +1,82 @@ +using MongoDB.Bson.Serialization.Attributes; +namespace GuestOps.Web; + +public interface ITenantDocument { string Id { get; set; } string HotelId { get; set; } } +public abstract class TenantDocument : ITenantDocument +{ + [BsonId] public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string HotelId { get; set; } = ""; +} +public class Hotel : TenantDocument +{ + public string Name { get; set; } = ""; + public string Timezone { get; set; } = "Europe/London"; + public string Signature { get; set; } = "Warm regards,\nThe reservations team"; + public string ReplyMode { get; set; } = "DraftOnly"; + public long Version { get; set; } +} +public class StaffUser : TenantDocument +{ + public string Email { get; set; } = ""; + public string Name { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public string Role { get; set; } = "Owner"; + public bool Active { get; set; } = true; +} +public class KnowledgeEntry : TenantDocument +{ + public string Title { get; set; } = ""; + public string Category { get; set; } = "General"; + public string Answer { get; set; } = ""; + public string Keywords { get; set; } = ""; + public bool Approved { get; set; } + public long Version { get; set; } +} +public class Conversation : TenantDocument +{ + public string MailboxId { get; set; } = ""; + public string ProviderMessageId { get; set; } = ""; + public string ProviderThreadId { get; set; } = ""; + public string From { get; set; } = ""; + public string Subject { get; set; } = ""; + public string Body { get; set; } = ""; + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; + public string Status { get; set; } = "NeedsAttention"; + public string Draft { get; set; } = ""; + public string Category { get; set; } = "General"; + public string Note { get; set; } = ""; + public long Version { get; set; } +} +public class Mailbox : TenantDocument +{ + public string Email { get; set; } = ""; + public string ProtectedRefreshToken { get; set; } = ""; + public string Status { get; set; } = "Connected"; + public DateTime? LastSyncAt { get; set; } + public string SyncError { get; set; } = ""; + public string PageToken { get; set; } = ""; + public DateTime WindowStart { get; set; } = DateTime.UtcNow.AddDays(-7); + public DateTime WindowEnd { get; set; } = DateTime.UtcNow; +} +public class OAuthRequest : TenantDocument +{ + public string UserId { get; set; } = ""; + public DateTime ExpiresAt { get; set; } +} +public class Activity : TenantDocument +{ + public DateTime At { get; set; } = DateTime.UtcNow; + public string UserName { get; set; } = ""; + public string Action { get; set; } = ""; +} +public class WorkerLease +{ + [BsonId] public string Id { get; set; } = ""; + public string Owner { get; set; } = ""; + public DateTime Until { get; set; } +} +public record LoginInput(string Email, string Password); +public record SettingsInput(string Name, string Timezone, string Signature, long Version); +public record DraftInput(string Draft, long Version); +public record StatusInput(string Status, long Version); +public record KnowledgeInput(string Title, string Category, string Answer, string Keywords, bool Approved, long Version); diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs new file mode 100644 index 0000000..5ebaea6 --- /dev/null +++ b/src/GuestOps.Api/Program.cs @@ -0,0 +1,184 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Threading.RateLimiting; +using GuestOps.Web; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); builder.Logging.AddConsole(); +builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); +builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning); +var preview = builder.Configuration.GetValue("Preview"); +if (preview && !builder.Environment.IsDevelopment()) throw new InvalidOperationException("Preview mode is Development-only."); +builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 128 * 1024); +var protection = builder.Services.AddDataProtection().SetApplicationName("GuestOps-Web"); +var keyPath = builder.Configuration["Keys:Path"]; +if (!preview && string.IsNullOrWhiteSpace(keyPath)) throw new InvalidOperationException("Keys:Path must point to a persistent private directory."); +if (keyPath != null) protection.PersistKeysToFileSystem(new DirectoryInfo(keyPath)); +builder.Services.AddSingleton(s => preview ? new PreviewStore() : new MongoStore(s.GetRequiredService())); +builder.Services.AddSingleton, PasswordHasher>(); +builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(25)); +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => +{ + o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax; + o.Cookie.SecurePolicy = preview ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; + o.ExpireTimeSpan = TimeSpan.FromHours(8); o.SlidingExpiration = false; + o.Events.OnRedirectToLogin = c => { c.Response.StatusCode = 401; return Task.CompletedTask; }; + o.Events.OnRedirectToAccessDenied = c => { c.Response.StatusCode = 403; return Task.CompletedTask; }; + o.Events.OnValidatePrincipal = async c => + { + var hotel = c.Principal?.FindFirstValue("hotel"); var id = c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + var user = hotel == null || id == null ? null : await c.HttpContext.RequestServices.GetRequiredService().Get(hotel, id); + if (user == null || !user.Active || user.Role != c.Principal!.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal(); + }; +}); +builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner"))); +builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.Name = "guestops.csrf"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = preview ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; }); +builder.Services.AddRateLimiter(o => +{ + o.RejectionStatusCode = 429; + o.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); +}); +var app = builder.Build(); +var store = app.Services.GetRequiredService(); +await store.Initialize(); +if (args.Contains("--bootstrap")) +{ + var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? ""; + var password = Environment.GetEnvironmentVariable("BOOTSTRAP_PASSWORD") ?? ""; + var hotelName = Environment.GetEnvironmentVariable("BOOTSTRAP_HOTEL") ?? ""; + if (!Input.Email(email) || password.Length < 14 || hotelName.Length < 2) throw new InvalidOperationException("Set BOOTSTRAP_EMAIL, BOOTSTRAP_PASSWORD (14+ characters), and BOOTSTRAP_HOTEL."); + if (await store.FindLogin(email) != null) throw new InvalidOperationException("Account already exists; bootstrap does not reset credentials."); + var hotel = new Hotel { Name = hotelName }; hotel.HotelId = hotel.Id; + var user = new StaffUser { HotelId = hotel.Id, Email = email, Name = "Hotel owner" }; + user.PasswordHash = app.Services.GetRequiredService>().HashPassword(user, password); + await store.Insert(hotel); await store.Insert(user); + Console.WriteLine("Hotel and owner account created."); return; +} +app.Use(async (ctx, next) => +{ + ctx.Response.Headers["X-Content-Type-Options"] = "nosniff"; + ctx.Response.Headers["Referrer-Policy"] = "same-origin"; + 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 (AntiforgeryValidationException) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Your session needs refreshing. Reload the page and try again." }); } + catch (Exception ex) + { + app.Logger.LogError("Request failed ({Type}), trace {Trace}", ex.GetType().Name, ctx.TraceIdentifier); + if (!ctx.Response.HasStarted) { ctx.Response.StatusCode = 500; await ctx.Response.WriteAsJsonAsync(new { error = "The request could not be completed. Please try again.", trace = ctx.TraceIdentifier }); } + } +}); +app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); +app.Use(async (ctx, next) => +{ + if (ctx.Request.Path.StartsWithSegments("/api") && !HttpMethods.IsGet(ctx.Request.Method) && !HttpMethods.IsHead(ctx.Request.Method)) + await ctx.RequestServices.GetRequiredService().ValidateRequestAsync(ctx); + await next(); +}); +app.MapGet("/health", () => Results.Ok(new { status = "ready" })); +app.MapGet("/api/session", (HttpContext c, IAntiforgery csrf) => Results.Ok(new +{ + preview, csrfToken = csrf.GetAndStoreTokens(c).RequestToken, + user = c.User.Identity?.IsAuthenticated == true ? new { id = c.User.FindFirstValue(ClaimTypes.NameIdentifier), name = c.User.Identity.Name, role = c.User.FindFirstValue(ClaimTypes.Role), hotelId = c.User.FindFirstValue("hotel") } : null +})); +app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPasswordHasher hasher) => +{ + if (input.Email.Length > 254 || input.Password.Length > 256) return Results.BadRequest(new { error = "Invalid credentials." }); + var user = await store.FindLogin(input.Email.Trim().ToLowerInvariant()); + // Verify a dummy hash too so unknown accounts do not have a fast password path. + var checkUser = user ?? new StaffUser(); + var hash = user?.PasswordHash ?? Input.DummyHash; + if (hasher.VerifyHashedPassword(checkUser, hash, input.Password) == PasswordVerificationResult.Failed || user?.Active != true) + return Results.Json(new { error = "Email or password is incorrect." }, statusCode: 401); + await Session.SignIn(c, user); return Results.Ok(); +}).RequireRateLimiting("login"); +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(); +api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get(Session.Hotel(c), Session.Hotel(c)))); +api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => +{ + if (!Input.Text(input.Name, 2, 120) || !Input.Text(input.Signature, 0, 2000)) return Results.BadRequest(new { error = "Enter a hotel name and a signature under 2,000 characters." }); + try { _ = TimeZoneInfo.FindSystemTimeZoneById(input.Timezone); } catch { return Results.BadRequest(new { error = "Choose a valid timezone." }); } + var hotel = await store.Get(Session.Hotel(c), Session.Hotel(c)); if (hotel == null) return Results.NotFound(); + hotel.Name = input.Name.Trim(); hotel.Signature = input.Signature; hotel.Timezone = input.Timezone; 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 hotel settings"); return Results.Ok(hotel); +}).RequireAuthorization("Owner"); +api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))); +api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) => +{ + if (!Input.Text(input.Draft, 0, 20000)) return Results.BadRequest(new { error = "Draft must be under 20,000 characters." }); + var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); + item.Draft = input.Draft; item.Version = input.Version + 1; if (item.Status != "Completed") item.Status = input.Draft.Length > 0 ? "DraftReady" : "NeedsAttention"; + if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict(); + await Session.Audit(store, c, "Saved a reply draft"); return Results.Ok(item); +}); +api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, HttpContext c) => +{ + if (input.Status is not ("Completed" or "NeedsAttention")) return Results.BadRequest(); + var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); + item.Status = input.Status; item.Version = input.Version + 1; + if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict(); + await Session.Audit(store, c, input.Status == "Completed" ? "Resolved a conversation" : "Reopened a conversation"); return Results.Ok(item); +}); +api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List(Session.Hotel(c)))); +api.MapPost("/knowledge", async (KnowledgeInput input, HttpContext c) => +{ + if (!Input.Knowledge(input)) return Results.BadRequest(new { error = "Enter a title and answer within the allowed lengths." }); + var item = new KnowledgeEntry { HotelId = Session.Hotel(c), Title = input.Title, Category = input.Category, Answer = input.Answer, Keywords = input.Keywords, Approved = input.Approved }; + await store.Insert(item); await Session.Audit(store, c, "Added a hotel knowledge entry"); return Results.Ok(item); +}).RequireAuthorization("Owner"); +api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContext c) => +{ + if (!Input.Knowledge(input)) return Results.BadRequest(); + var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); + item.Title = input.Title; item.Category = input.Category; item.Answer = input.Answer; item.Keywords = input.Keywords; item.Approved = input.Approved; item.Version = input.Version + 1; + if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict(); + await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item); +}).RequireAuthorization("Owner"); +api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))); +api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google) => Results.Ok(new { configured = !preview && google.Configured, items = (await store.List(Session.Hotel(c))).Select(x => new { x.Id, x.Email, x.Status, x.LastSyncAt, x.SyncError }) })); +api.MapPost("/integrations/google/connect", async (HttpContext c, GoogleMailbox google) => +{ + if (preview || !google.Configured) return Results.BadRequest(new { error = "Google connection has not been configured by the administrator." }); + var state = new OAuthRequest { Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), HotelId = Session.Hotel(c), UserId = c.User.FindFirstValue(ClaimTypes.NameIdentifier)!, ExpiresAt = DateTime.UtcNow.AddMinutes(10) }; + await store.Insert(state); return Results.Ok(new { url = google.AuthorizationUrl(state.Id) }); +}).RequireAuthorization("Owner"); +api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox google) => +{ + if (preview) return Results.BadRequest(); + var state = await store.ConsumeOAuth(c.Request.Query["state"].ToString(), Session.Hotel(c), c.User.FindFirstValue(ClaimTypes.NameIdentifier)!); + if (state == null) return Results.BadRequest(new { error = "The connection request expired. Start again from Settings." }); + if (c.Request.Query.ContainsKey("error")) return Results.Redirect("/settings?google=cancelled"); + try { await google.Connect(Session.Hotel(c), c.Request.Query["code"].ToString()); await Session.Audit(store, c, "Connected Google mailbox"); } + catch { return Results.Redirect("/settings?google=failed"); } + return Results.Redirect("/settings?google=connected"); +}).RequireAuthorization("Owner"); +app.UseDefaultFiles(); app.UseStaticFiles(); +app.MapFallbackToFile("index.html"); +app.Run(); + +namespace GuestOps.Web +{ + public static class Session + { + public static string Hotel(HttpContext c) => c.User.FindFirstValue("hotel") ?? throw new InvalidOperationException("Missing hotel membership"); + public static Task SignIn(HttpContext c, StaffUser u) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, u.Role), new Claim("hotel", u.HotelId) }, CookieAuthenticationDefaults.AuthenticationScheme))); + public static Task Audit(IStore store, HttpContext c, string action) => store.Insert(new Activity { HotelId = Hotel(c), UserName = c.User.Identity?.Name ?? "Staff", Action = action }); + } + public static class Input + { + public static readonly string DummyHash = new PasswordHasher().HashPassword(new(), Convert.ToHexString(RandomNumberGenerator.GetBytes(32))); + public static bool Text(string? s, int min, int max) => s != null && s.Trim().Length >= min && s.Length <= max; + public static bool Email(string s) => System.Net.Mail.MailAddress.TryCreate(s, out var address) && address.Address == s; + public static bool Knowledge(KnowledgeInput s) => Text(s.Title, 2, 120) && Text(s.Answer, 2, 5000) && Text(s.Category, 1, 50) && Text(s.Keywords, 0, 300); + public static IResult Conflict() => Results.Conflict(new { error = "Someone changed this record. Reload it before saving again." }); + } +} diff --git a/src/GuestOps.Api/Store.cs b/src/GuestOps.Api/Store.cs new file mode 100644 index 0000000..62f9992 --- /dev/null +++ b/src/GuestOps.Api/Store.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using MongoDB.Driver; +namespace GuestOps.Web; + +// All application reads/writes require a server-derived hotel ID. Global access is +// confined to authentication and the mailbox worker, not exposed as query parameters. +public interface IStore +{ + Task Initialize(); + Task> List(string hotel) where T : TenantDocument; + Task Get(string hotel, string id) where T : TenantDocument; + Task Insert(T document) where T : TenantDocument; + Task Replace(string hotel, string id, long version, T document) where T : TenantDocument; + Task Delete(string hotel, string id) where T : TenantDocument; + Task FindLogin(string email); + Task ConsumeOAuth(string id, string hotel, string user); + Task> Mailboxes(); + Task SaveMailbox(Mailbox mailbox); + Task SaveSync(Mailbox mailbox); + Task TryLease(string id, string owner); + Task ReleaseLease(string id, string owner); + Task Import(Conversation message); +} +public sealed class MongoStore : IStore +{ + private readonly IMongoDatabase db; + public MongoStore(IConfiguration config) + { + var connection = config["Mongo:ConnectionString"] ?? throw new InvalidOperationException("Mongo:ConnectionString is required."); + var settings = MongoClientSettings.FromConnectionString(connection); + settings.ServerSelectionTimeout = TimeSpan.FromSeconds(8); + db = new MongoClient(settings).GetDatabase(config["Mongo:Database"] ?? "guestops"); + } + IMongoCollection Collection() => db.GetCollection(typeof(T).Name.ToLowerInvariant()); + FilterDefinition Scope(string hotel) where T : TenantDocument + { + if (string.IsNullOrWhiteSpace(hotel)) throw new InvalidOperationException("Hotel scope required."); + return Builders.Filter.Eq(x => x.HotelId, hotel); + } + public async Task Initialize() + { + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Email), new() { Unique = true })); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.HotelId).Ascending(x => x.MailboxId).Ascending(x => x.ProviderMessageId), new() { Unique = true })); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.HotelId).Descending(x => x.ReceivedAt))); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Email), new() { Unique = true })); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.ExpiresAt), new() { ExpireAfter = TimeSpan.Zero })); + } + public Task> List(string hotel) where T : TenantDocument + { + var query = Collection().Find(Scope(hotel)); + if (typeof(T) == typeof(Conversation)) query = query.Sort(Builders.Sort.Descending("ReceivedAt")); + if (typeof(T) == typeof(Activity)) query = query.Sort(Builders.Sort.Descending("At")); + return query.Limit(500).ToListAsync(); + } + public async Task Get(string hotel, string id) where T : TenantDocument => await Collection().Find(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync(); + public Task Insert(T document) where T : TenantDocument + { + _ = Scope(document.HotelId); + return Collection().InsertOneAsync(document); + } + public async Task Replace(string hotel, string id, long version, T document) where T : TenantDocument + { + if (document.HotelId != hotel || document.Id != id) throw new InvalidOperationException("Invalid document scope."); + var result = await Collection().ReplaceOneAsync(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id) & Builders.Filter.Eq("Version", version), document); + return result.ModifiedCount == 1; + } + public async Task Delete(string hotel, string id) where T : TenantDocument => await Collection().DeleteOneAsync(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id)); + public async Task FindLogin(string email) => await Collection().Find(x => x.Email == email).FirstOrDefaultAsync(); + public async Task ConsumeOAuth(string id, string hotel, string user) => await Collection().FindOneAndDeleteAsync(x => x.Id == id && x.HotelId == hotel && x.UserId == user && x.ExpiresAt > DateTime.UtcNow); + public Task> Mailboxes() => Collection().Find(x => x.Status == "Connected").ToListAsync(); + public async Task SaveMailbox(Mailbox mailbox) => await Collection().ReplaceOneAsync(x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id, mailbox, new ReplaceOptions { IsUpsert = true }); + public async Task SaveSync(Mailbox mailbox) => await Collection().UpdateOneAsync( + x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id && x.ProtectedRefreshToken == mailbox.ProtectedRefreshToken, + Builders.Update.Set(x => x.LastSyncAt, mailbox.LastSyncAt).Set(x => x.SyncError, mailbox.SyncError) + .Set(x => x.PageToken, mailbox.PageToken).Set(x => x.WindowStart, mailbox.WindowStart).Set(x => x.WindowEnd, mailbox.WindowEnd)); + public async Task TryLease(string id, string owner) + { + try + { + var result = await Collection().FindOneAndUpdateAsync(x => x.Id == id && x.Until < DateTime.UtcNow, + Builders.Update.Set(x => x.Owner, owner).Set(x => x.Until, DateTime.UtcNow.AddMinutes(5)), new() { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + return result.Owner == owner; + } + catch (MongoCommandException ex) when (ex.Code == 11000) { return false; } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { return false; } + } + public async Task ReleaseLease(string id, string owner) => await Collection().DeleteOneAsync(x => x.Id == id && x.Owner == owner); + public async Task Import(Conversation message) + { + try { await Insert(message); } + catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { /* already durable */ } + } +} + +// Explicit Development-only preview store. Production never falls back to this. +public sealed class PreviewStore : IStore +{ + private readonly ConcurrentDictionary rows = new(); + private readonly object gate = new(); + static string Key(string id) => typeof(T).Name + ":" + id; + static T Clone(string text) => System.Text.Json.JsonSerializer.Deserialize(text)!; + static string Json(T value) => System.Text.Json.JsonSerializer.Serialize(value); + public Task Initialize() => Task.CompletedTask; + public Task> List(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone(x.Value)).Where(x => x.HotelId == hotel).ToList()); + public async Task Get(string hotel, string id) where T : TenantDocument => (await List(hotel)).SingleOrDefault(x => x.Id == id); + public Task Insert(T document) where T : TenantDocument { if (!rows.TryAdd(Key(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; } + public Task Replace(string hotel, string id, long version, T document) where T : TenantDocument + { + lock (gate) + { + if (!rows.TryGetValue(Key(id), out var raw)) return Task.FromResult(false); + var old = Clone(raw); + if (old.HotelId != hotel || document.HotelId != hotel || document.Id != id || (long)typeof(T).GetProperty("Version")!.GetValue(old)! != version) return Task.FromResult(false); + rows[Key(id)] = Json(document); return Task.FromResult(true); + } + } + public async Task Delete(string hotel, string id) where T : TenantDocument { if (await Get(hotel, id) != null) rows.TryRemove(Key(id), out _); } + public Task FindLogin(string email) => Task.FromResult(rows.Where(x => x.Key.StartsWith("StaffUser:")).Select(x => Clone(x.Value)).SingleOrDefault(x => x.Email == email)); + public Task ConsumeOAuth(string id, string hotel, string user) { lock(gate) { var x = rows.TryGetValue(Key(id), out var raw) ? Clone(raw) : null; if(x?.HotelId != hotel || x.UserId != user || x.ExpiresAt <= DateTime.UtcNow) return Task.FromResult(null); rows.TryRemove(Key(id),out _); return Task.FromResult(x); } } + public Task> Mailboxes() => Task.FromResult(new List()); + public Task SaveMailbox(Mailbox mailbox) => throw new InvalidOperationException("Real mailbox connections are unavailable in preview mode."); + public Task SaveSync(Mailbox mailbox) => throw new InvalidOperationException("Real mailbox connections are unavailable in preview mode."); + public Task TryLease(string id, string owner) => Task.FromResult(false); + public Task ReleaseLease(string id, string owner) => Task.CompletedTask; + public async Task Import(Conversation message) { if (!(await List(message.HotelId)).Any(x => x.MailboxId == message.MailboxId && x.ProviderMessageId == message.ProviderMessageId)) await Insert(message); } +} diff --git a/src/GuestOps.Core/AiExtractionPrompt.cs b/src/GuestOps.Core/AiExtractionPrompt.cs new file mode 100644 index 0000000..dc1f3e7 --- /dev/null +++ b/src/GuestOps.Core/AiExtractionPrompt.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using GuestOps.Models; + +namespace GuestOps.Services; + +/// +/// Shared prompt-builder and JSON-parser used by every AI provider +/// (Ollama, OpenAI, ...). Each provider just sends the prompt to its +/// model and feeds the raw text response into . +/// +public static class AiExtractionPrompt +{ + /// Maximum email body characters embedded in the prompt. + private const int MaxBodyChars = 12_000; + + public static string Build(ParsedBooking b) => Build(b, customAddendum: ""); + + public static string Build(ParsedBooking b, string customAddendum) + { + var cleanedBody = EmailBodyCleaner.Truncate(EmailBodyCleaner.Clean(b.EmailBody), MaxBodyChars); + var addendum = string.IsNullOrWhiteSpace(customAddendum) + ? "" + : $"\nADDITIONAL HOTEL-SPECIFIC RULES (operator-supplied):\n{customAddendum.Trim()}\n"; + + return $$""" + You are an email parser for a hotel reservations team. Read the email below and + extract a JSON object that lists EVERY booking or amendment described. + A single email can describe multiple bookings (e.g. three different lead guests + in the same message), so always return an array even if only one item exists. + Do not invent fields. If a value is not mentioned, set it to null or "". + + If the email is written in a language other than English, mentally translate it + before extracting and return all string fields in English (room types, rate plans + and product codes must stay verbatim - do NOT translate codes). + + Use action = "quote_request" when the guest is ASKING FOR A PRICE / availability + rather than making or amending an existing booking. Typical phrasings: + "how much for...", "what would it cost...", "do you have a room for...", + "can you quote me...", "what's your rate for...". For quote_request, set + checkin + nights + adults/children + room_type (best guess from the email); + confirmation_number can be empty. + {{addendum}} + Output shape (return EXACTLY this object): + { + "actions": [ + { + "action": one of [create, cancel, change_checkin, change_room, + change_rate, add_package, remove_package, + update_email, update_phone, update_comment, + send_invoice, quote_request, none], + "subject": string (copy of the email subject line), + "first_name": string, + "last_name": string, + "adults": integer, + "children": integer, + "original_checkin": "YYYY-MM-DD" or null (only for change-of-arrival), + "checkin": "YYYY-MM-DD" or null, + "nights": integer, + "rooms": integer, + "room_type": string, + "rate_plan": string, + "package_code": string, + "upsells": string (comma-separated extras), + "payment_guarantee": string, + "comment": string, + "email": string, + "phone": string, + "new_email": string (only for update_email), + "new_phone": string (only for update_phone), + "cancellation_reason": string (only for cancel), + "confirmation_number": string, + "opera_res_id": string, + "confidence": integer 0-100, + "rationale": one-sentence explanation + } + ] + } + + EMAIL SUBJECT: {{b.EmailSubject}} + EMAIL FROM: {{b.EmailFrom}} + EMAIL DATE: {{b.EmailDate:yyyy-MM-dd HH:mm}} + EMAIL BODY (cleaned): + --- + {{cleanedBody}} + --- + Respond ONLY with the JSON object. + """; + } + + public static List BuildRowsFromJson(string raw, ParsedBooking seed) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(raw)) + { + seed.Status = RowStatus.LowConfidence; + seed.StatusMessage = "Empty AI response"; + result.Add(seed); + return result; + } + + var jsonText = ExtractFirstJsonObject(raw); + JsonDocument doc; + try { doc = JsonDocument.Parse(jsonText); } + catch (Exception ex) + { + seed.Status = RowStatus.LowConfidence; + seed.StatusMessage = "AI returned invalid JSON: " + ex.Message; + result.Add(seed); + return result; + } + + using (doc) + { + var root = doc.RootElement; + + JsonElement actionsElement = default; + bool hasArray = false; + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("actions", out actionsElement) && + actionsElement.ValueKind == JsonValueKind.Array) + { + hasArray = true; + } + + if (hasArray) + { + bool first = true; + foreach (var item in actionsElement.EnumerateArray()) + { + var row = first ? seed : CloneSeed(seed); + ApplyJsonToBooking(item, row); + result.Add(row); + first = false; + } + if (result.Count == 0) + { + seed.Status = RowStatus.LowConfidence; + seed.StatusMessage = "AI returned no actions"; + result.Add(seed); + } + } + else + { + ApplyJsonToBooking(root, seed); + result.Add(seed); + } + } + return result; + } + + private static ParsedBooking CloneSeed(ParsedBooking s) => new() + { + EmailUid = s.EmailUid, + MailboxScope = s.MailboxScope, ImapUid = s.ImapUid, UidValidity = s.UidValidity, + MessageId = s.MessageId, References = s.References, ReplyTo = s.ReplyTo, + IsAutomaticMessage = s.IsAutomaticMessage, + EmailSubject = s.EmailSubject, + EmailFrom = s.EmailFrom, + EmailDate = s.EmailDate, + EmailBody = s.EmailBody, + Status = RowStatus.New + }; + + private static void ApplyJsonToBooking(JsonElement root, ParsedBooking b) + { + b.Action = MapAction(GetStr(root, "action")); + var aiSubject = GetStr(root, "subject"); + if (!string.IsNullOrWhiteSpace(aiSubject)) b.EmailSubject = aiSubject; + + b.GuestFirstName = GetStr(root, "first_name"); + b.GuestLastName = GetStr(root, "last_name"); + b.Adults = GetInt(root, "adults", 1); + b.Children = GetInt(root, "children", 0); + b.OriginalCheckIn = GetDate(root, "original_checkin"); + b.ArrivalDate = GetDate(root, "checkin"); + b.Nights = Math.Max(1, GetInt(root, "nights", 1)); + b.Rooms = Math.Max(1, GetInt(root, "rooms", 1)); + if (b.ArrivalDate is DateTime a) + b.DepartureDate = a.AddDays(b.Nights); + + b.RoomType = GetStr(root, "room_type"); + b.RatePlanCode = GetStr(root, "rate_plan"); + b.PackageCode = GetStr(root, "package_code"); + b.Upsells = GetStr(root, "upsells"); + b.PaymentGuarantee = GetStr(root, "payment_guarantee"); + b.Comment = GetStr(root, "comment"); + b.GuestEmail = GetStr(root, "email"); + b.GuestPhone = GetStr(root, "phone"); + b.NewEmail = GetStr(root, "new_email"); + b.NewPhone = GetStr(root, "new_phone"); + b.CancellationReason = GetStr(root, "cancellation_reason"); + b.ConfirmationNumber = GetStr(root, "confirmation_number"); + b.ReservationId = GetStr(root, "opera_res_id"); + b.Confidence = Math.Clamp(GetInt(root, "confidence", 0), 0, 100); + b.AiRationale = GetStr(root, "rationale"); + + b.Status = b.Action == BookingAction.None ? RowStatus.LowConfidence : RowStatus.New; + } + + private static string ExtractFirstJsonObject(string raw) + { + var text = raw.Trim(); + if (text.StartsWith("```", StringComparison.Ordinal)) + { + var newline = text.IndexOf('\n'); + if (newline >= 0 && text.EndsWith("```", StringComparison.Ordinal)) + text = text[(newline + 1)..^3].Trim(); + } + return text; // JsonDocument handles braces and escapes inside strings correctly. + } + + private static string GetStr(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() ?? "" : ""; + + private static int GetInt(JsonElement e, string name, int def) + { + if (!e.TryGetProperty(name, out var v)) return def; + if (v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var n)) return n; + if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var s)) return s; + return def; + } + + private static DateTime? GetDate(JsonElement e, string name) + { + if (!e.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String) return null; + var s = v.GetString(); + return DateTime.TryParse(s, out var d) ? d : (DateTime?)null; + } + + private static BookingAction MapAction(string action) => action?.Trim().ToLowerInvariant() switch + { + "create" => BookingAction.CreateBooking, + "cancel" => BookingAction.CancelBooking, + "change_checkin" => BookingAction.ChangeCheckIn, + "change_room" => BookingAction.ChangeRoomType, + "change_rate" => BookingAction.ChangeRate, + "add_package" => BookingAction.AddPackage, + "remove_package" => BookingAction.RemovePackage, + "update_email" => BookingAction.UpdateEmail, + "update_phone" => BookingAction.UpdatePhone, + "update_comment" => BookingAction.UpdateComment, + "send_invoice" => BookingAction.SendInvoice, + "quote_request" => BookingAction.QuoteRequest, + "rate_quote" => BookingAction.QuoteRequest, + "quote" => BookingAction.QuoteRequest, + _ => BookingAction.None + }; +} + +/// Common contract for any LLM that turns one email seed into 1+ rows. +public interface IAiExtractor +{ + System.Threading.Tasks.Task> ExtractAllAsync( + ParsedBooking seed, System.Threading.CancellationToken ct = default); +} + diff --git a/src/GuestOps.Core/BookingValidation.cs b/src/GuestOps.Core/BookingValidation.cs new file mode 100644 index 0000000..66f7c66 --- /dev/null +++ b/src/GuestOps.Core/BookingValidation.cs @@ -0,0 +1,23 @@ +using GuestOps.Models; +namespace GuestOps.Services; + +public static class BookingValidation +{ + public static bool IsCurrency(string? value) => value != null && value.Length == 3 && value.All(c => c is >= 'A' and <= 'Z' or >= 'a' and <= 'z'); + public static string? Stay(ParsedBooking row) + { + if (row.Rooms != 1) return "Multi-room requests require staff handling; split into individually priced reservations before taking payment."; + if (!row.ArrivalDate.HasValue || !row.DepartureDate.HasValue || row.DepartureDate <= row.ArrivalDate || row.ArrivalDate.Value.Date < DateTime.Today) + return "A valid future arrival and later departure are required."; + if (row.Adults < 1 || row.Children < 0) return "Guest counts are invalid."; + return null; + } + public static string? Validate(ParsedBooking row) + { + if (row.IsAutomaticMessage) return "Automated/list/bounce messages are not eligible for processing."; + if (row.Action == BookingAction.None) return "Review or reparse this email; no supported action is selected."; + if (row.Action is BookingAction.CreateBooking or BookingAction.QuoteRequest) return Stay(row); + if (string.IsNullOrWhiteSpace(row.ConfirmationNumber)) return "A confirmation number is required."; + return null; + } +} diff --git a/src/GuestOps.Core/EmailBodyCleaner.cs b/src/GuestOps.Core/EmailBodyCleaner.cs new file mode 100644 index 0000000..e37da70 --- /dev/null +++ b/src/GuestOps.Core/EmailBodyCleaner.cs @@ -0,0 +1,73 @@ +using System; +using System.Text; +using System.Text.RegularExpressions; + +namespace GuestOps.Services; + +/// +/// Lightweight, dependency-free pre-cleanup of email bodies before they +/// are handed to the AI. Removes the bulk of HTML, common signature +/// blocks, forwarded-reply quotes, mail-client footers and excess +/// whitespace. Keeps the body readable for the AI without changing the +/// underlying meaning. +/// +public static class EmailBodyCleaner +{ + private static readonly Regex HtmlTagRx = new(@"<[^>]+>", RegexOptions.Compiled | RegexOptions.Singleline); + private static readonly Regex HtmlEntityRx = new(@"&(nbsp|amp|lt|gt|quot|#\d+);", RegexOptions.Compiled); + private static readonly Regex StyleScriptRx = new(@"<(script|style)[^>]*>.*?", RegexOptions.Compiled | RegexOptions.Singleline | RegexOptions.IgnoreCase); + private static readonly Regex BlankLinesRx = new(@"(\r?\n){3,}", RegexOptions.Compiled); + private static readonly Regex QuoteHeaderRx = new(@"(?im)^(on .+ wrote:|from:\s.+|le .+ a écrit\s*:|am .+ schrieb:)$.*", RegexOptions.Compiled); + private static readonly Regex QuotedLineRx = new(@"(?m)^>.*$\r?\n?", RegexOptions.Compiled); + private static readonly Regex SignatureRx = new( + @"(?ms)\r?\n--\s*\r?\n.*$" + + @"|(?ms)\r?\n_{6,}\r?\n.*$" + + @"|(?ms)\r?\nsent from my (iphone|ipad|android|samsung).*$" + + @"|(?ms)\r?\n(get outlook for|this email and any attachments).*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static string Clean(string body) + { + if (string.IsNullOrWhiteSpace(body)) return ""; + + var s = body; + + // 1. Strip diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..d431bca --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,931 @@ +{ + "name": "guestops-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "guestops-web", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.577.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "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" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/rolldown/node_modules/@rolldown/binding-openharmony-arm64": { + "dev": true, + "optional": true + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..0d96715 --- /dev/null +++ b/web/package.json @@ -0,0 +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"}} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..4798b89 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..145b595 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,18 @@ +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 }; +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 }; +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 Mailboxes = { configured: boolean; items: { id: string; email: string; status: string; lastSyncAt: string | null; syncError: string }[] }; +let csrf = ''; +export async function api(path: string, method = 'GET', body?: unknown): Promise { + 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 data = await response.json().catch(() => null); + if (!response.ok) { + if (response.status === 401 && path !== '/auth/login') window.dispatchEvent(new Event('session-expired')); + throw new Error(data?.error || (response.status === 429 ? 'Too many attempts. Please wait a minute.' : response.status === 403 ? 'Only the hotel owner can change this.' : 'We couldn’t complete that request. Please try again.')); + } + return data as T; +} +export async function session() { const value = await api('/session'); csrf = value.csrfToken; return value; } diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3ba82f7 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,81 @@ +import React, { useEffect, useState } from 'react'; +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'; + +const labels: Record = { 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 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' }); + +function App() { + const [auth,setAuth] = useState(null), [error,setError] = useState(''), [notice,setNotice] = useState(''); + const [hotel,setHotel] = useState(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false); + const [conversations,setConversations] = useState([]), [knowledge,setKnowledge] = useState([]), [activity,setActivity] = useState([]), [mailboxes,setMailboxes] = useState({ configured:false,items:[] }); + const [loaded,setLoaded] = useState(false); + async function refresh() { + const [h,c,k,a,m] = await Promise.all([api('/hotel'),api('/conversations'),api('/knowledge'),api('/activity'),api('/mailboxes')]); + setHotel(h);setConversations(c);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);}; },[]); + 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]); + async function run(action:()=>Promise) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} } + function go(path:string) { if (!window.dispatchEvent(new Event('workspace-navigate', { cancelable:true }))) return; setPage(path);history.pushState({},'',path);setError(''); } + async function login(email:string,password:string) { await run(async()=>{await api('/auth/login','POST',{email,password});setAuth(await session());}); } + async function preview() { await run(async()=>{await api('/preview/start','POST');setAuth(await session());}); } + async function logout() { await run(async()=>{await api('/auth/logout','POST');setAuth(await session());setHotel(null);setLoaded(false);}); } + const errorBox=error?
{error}
:null; + if(!auth) return
g

Opening your workspace…

{errorBox}
; + if(!auth.user) return ; + const count=conversations.filter(c=>c.status!=='Completed').length; + return
+ +
+
Workspace{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':'Settings'}
{auth.preview&&Preview · sample data}Draft-only mode
+ {errorBox}{notice&&
{notice}
} + {!loaded?

Loading your hotel…

:page==='/inbox'?setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/knowledge'?setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?
{activity.length?activity.map(a=>
{a.action}

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>} +
+
; +} +function PageHeading({eyebrow,title,text}:{eyebrow:string;title:string;text:string}) { return
{eyebrow}

{title}

{text}

; } +function Empty({title,text}:{title:string;text:string}) {return

{title}

{text}

;} +function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise;onPreview:()=>Promise;busy:boolean;error:React.ReactNode}){ + const [email,setEmail]=useState(''),[password,setPassword]=useState(''); + return
gguestops.
A little more time for your guests

Great hospitality.
A calmer inbox.

Your conversations, hotel knowledge and team.
Together in one thoughtful workspace.

Less time sorting emails.
More time making guests feel welcome.
Built around the way hotels work.
Your hotel workspace

Welcome back

Sign in to take care of your guests.

{error}
{e.preventDefault();onLogin(email,password);}}>

Need access or help signing in? Contact your hotel administrator.

{preview&&
Explore the interface with sample conversations.Preview changes are temporary. No real emails are sent.
}
; +} +type Run=(a:()=>Promise)=>Promise; +function InboxPage({conversations,knowledge,busy,run,onUpdate,notify,go}:{conversations:Conversation[];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(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 current=filtered.find(c=>c.id===selected)||filtered[0]; + useEffect(()=>{setDraft(current?.draft||'');},[current?.id,current?.draft]); + useEffect(()=>{if(!current || draft===current.draft)return;const leave=(e:BeforeUnloadEvent)=>{e.preventDefault();};const navigate=(e:Event)=>{if(!window.confirm('Discard your unsaved draft changes?'))e.preventDefault();};window.addEventListener('beforeunload',leave);window.addEventListener('workspace-navigate',navigate);return()=>{window.removeEventListener('beforeunload',leave);window.removeEventListener('workspace-navigate',navigate);};},[current?.id,current?.draft,draft]); + const needs=conversations.filter(c=>c.status==='NeedsAttention').length,ready=conversations.filter(c=>c.status==='DraftReady').length; + function select(c:Conversation) { if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setSelected(c.id);setMobileDetail(true); } + async function save() {if(!current)return;await run(async()=>{onUpdate(await api(`/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(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});} + return
{needs}Need attention
{ready}Drafts ready
+
{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=>)}
+ {!conversations.length?
:
{filtered.length} conversation{filtered.length===1?'':'s'}Newest first
{filtered.map((c,i)=>)}{!filtered.length&&}
+
{current?<>
{current.category}

{current.subject}

{initials(sender(current.from))}
{sender(current.from)}To your hotel · {date(current.receivedAt)}
{current.body}
{current.note&&
{current.note}
}
Your reply draftOnly visible to your team