Build private multi-hotel web foundation with MongoDB, Gmail import and Linux deployment
This commit is contained in:
parent
dec97954c9
commit
49d02be623
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
**/keys
|
||||||
|
tests
|
||||||
|
*.tar*
|
||||||
9
.env.example
Normal file
9
.env.example
Normal file
@ -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
|
||||||
53
.github/workflows/web.yml
vendored
Normal file
53
.github/workflows/web.yml
vendored
Normal file
@ -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
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/appsettings.Local.json
|
||||||
|
**/keys/
|
||||||
|
*.log
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
.DS_Store
|
||||||
1
Directory.Build.props
Normal file
1
Directory.Build.props
Normal file
@ -0,0 +1 @@
|
|||||||
|
<Project><PropertyGroup><TargetFramework>net10.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings><TreatWarningsAsErrors>true</TreatWarningsAsErrors></PropertyGroup></Project>
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@ -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"]
|
||||||
58
README.md
58
README.md
@ -1,5 +1,59 @@
|
|||||||
# GuestOps Web
|
# 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).
|
||||||
|
|||||||
61
compose.yml
Normal file
61
compose.yml
Normal file
@ -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:
|
||||||
2
deploy/mongo-init.js
Normal file
2
deploy/mongo-init.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
const guestops = db.getSiblingDB('guestops');
|
||||||
|
guestops.createUser({user: 'guestops', pwd: process.env.MONGO_APP_PASSWORD, roles: [{role: 'readWrite', db: 'guestops'}]});
|
||||||
31
deploy/nginx.conf
Normal file
31
deploy/nginx.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
docs/deployment.md
Normal file
73
docs/deployment.md
Normal file
@ -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:<commit-sha>` and `GUESTOPS_WORKER_IMAGE=guestops-worker:<commit-sha>` 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.
|
||||||
31
docs/migration.md
Normal file
31
docs/migration.md
Normal file
@ -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.
|
||||||
22
src/GuestOps.Api/Demo.cs
Normal file
22
src/GuestOps.Api/Demo.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
namespace GuestOps.Web;
|
||||||
|
public static class Demo
|
||||||
|
{
|
||||||
|
public static async Task<StaffUser> 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 + " <guest@example.invalid>", 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/GuestOps.Api/GoogleMailbox.cs
Normal file
79
src/GuestOps.Api/GoogleMailbox.cs
Normal file
@ -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<string,string> {
|
||||||
|
["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<JsonElement> Token(Dictionary<string, string> 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<JsonElement> 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<Mailbox>(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)) : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/GuestOps.Api/GuestOps.Api.csproj
Normal file
1
src/GuestOps.Api/GuestOps.Api.csproj
Normal file
@ -0,0 +1 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web"><ItemGroup><ProjectReference Include="../GuestOps.Core/GuestOps.Core.csproj"/><PackageReference Include="MongoDB.Driver" Version="3.11.1"/></ItemGroup></Project>
|
||||||
82
src/GuestOps.Api/Models.cs
Normal file
82
src/GuestOps.Api/Models.cs
Normal file
@ -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);
|
||||||
184
src/GuestOps.Api/Program.cs
Normal file
184
src/GuestOps.Api/Program.cs
Normal file
@ -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<bool>("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<IStore>(s => preview ? new PreviewStore() : new MongoStore(s.GetRequiredService<IConfiguration>()));
|
||||||
|
builder.Services.AddSingleton<IPasswordHasher<StaffUser>, PasswordHasher<StaffUser>>();
|
||||||
|
builder.Services.AddHttpClient<GoogleMailbox>(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<IStore>().Get<StaffUser>(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<IStore>();
|
||||||
|
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<IPasswordHasher<StaffUser>>().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<IAntiforgery>().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<StaffUser> 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<Hotel>(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<Hotel>(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<Conversation>(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<Conversation>(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<Conversation>(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<KnowledgeEntry>(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<KnowledgeEntry>(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<Activity>(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<Mailbox>(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<StaffUser>().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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/GuestOps.Api/Store.cs
Normal file
126
src/GuestOps.Api/Store.cs
Normal file
@ -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<T>> List<T>(string hotel) where T : TenantDocument;
|
||||||
|
Task<T?> Get<T>(string hotel, string id) where T : TenantDocument;
|
||||||
|
Task Insert<T>(T document) where T : TenantDocument;
|
||||||
|
Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument;
|
||||||
|
Task Delete<T>(string hotel, string id) where T : TenantDocument;
|
||||||
|
Task<StaffUser?> FindLogin(string email);
|
||||||
|
Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user);
|
||||||
|
Task<List<Mailbox>> Mailboxes();
|
||||||
|
Task SaveMailbox(Mailbox mailbox);
|
||||||
|
Task SaveSync(Mailbox mailbox);
|
||||||
|
Task<bool> 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<T> Collection<T>() => db.GetCollection<T>(typeof(T).Name.ToLowerInvariant());
|
||||||
|
FilterDefinition<T> Scope<T>(string hotel) where T : TenantDocument
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hotel)) throw new InvalidOperationException("Hotel scope required.");
|
||||||
|
return Builders<T>.Filter.Eq(x => x.HotelId, hotel);
|
||||||
|
}
|
||||||
|
public async Task Initialize()
|
||||||
|
{
|
||||||
|
await Collection<StaffUser>().Indexes.CreateOneAsync(new CreateIndexModel<StaffUser>(Builders<StaffUser>.IndexKeys.Ascending(x => x.Email), new() { Unique = true }));
|
||||||
|
await Collection<Conversation>().Indexes.CreateOneAsync(new CreateIndexModel<Conversation>(Builders<Conversation>.IndexKeys.Ascending(x => x.HotelId).Ascending(x => x.MailboxId).Ascending(x => x.ProviderMessageId), new() { Unique = true }));
|
||||||
|
await Collection<Conversation>().Indexes.CreateOneAsync(new CreateIndexModel<Conversation>(Builders<Conversation>.IndexKeys.Ascending(x => x.HotelId).Descending(x => x.ReceivedAt)));
|
||||||
|
await Collection<Mailbox>().Indexes.CreateOneAsync(new CreateIndexModel<Mailbox>(Builders<Mailbox>.IndexKeys.Ascending(x => x.Email), new() { Unique = true }));
|
||||||
|
await Collection<OAuthRequest>().Indexes.CreateOneAsync(new CreateIndexModel<OAuthRequest>(Builders<OAuthRequest>.IndexKeys.Ascending(x => x.ExpiresAt), new() { ExpireAfter = TimeSpan.Zero }));
|
||||||
|
}
|
||||||
|
public Task<List<T>> List<T>(string hotel) where T : TenantDocument
|
||||||
|
{
|
||||||
|
var query = Collection<T>().Find(Scope<T>(hotel));
|
||||||
|
if (typeof(T) == typeof(Conversation)) query = query.Sort(Builders<T>.Sort.Descending("ReceivedAt"));
|
||||||
|
if (typeof(T) == typeof(Activity)) query = query.Sort(Builders<T>.Sort.Descending("At"));
|
||||||
|
return query.Limit(500).ToListAsync();
|
||||||
|
}
|
||||||
|
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().Find(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync();
|
||||||
|
public Task Insert<T>(T document) where T : TenantDocument
|
||||||
|
{
|
||||||
|
_ = Scope<T>(document.HotelId);
|
||||||
|
return Collection<T>().InsertOneAsync(document);
|
||||||
|
}
|
||||||
|
public async Task<bool> Replace<T>(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<T>().ReplaceOneAsync(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id) & Builders<T>.Filter.Eq("Version", version), document);
|
||||||
|
return result.ModifiedCount == 1;
|
||||||
|
}
|
||||||
|
public async Task Delete<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().DeleteOneAsync(Scope<T>(hotel) & Builders<T>.Filter.Eq(x => x.Id, id));
|
||||||
|
public async Task<StaffUser?> FindLogin(string email) => await Collection<StaffUser>().Find(x => x.Email == email).FirstOrDefaultAsync();
|
||||||
|
public async Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user) => await Collection<OAuthRequest>().FindOneAndDeleteAsync(x => x.Id == id && x.HotelId == hotel && x.UserId == user && x.ExpiresAt > DateTime.UtcNow);
|
||||||
|
public Task<List<Mailbox>> Mailboxes() => Collection<Mailbox>().Find(x => x.Status == "Connected").ToListAsync();
|
||||||
|
public async Task SaveMailbox(Mailbox mailbox) => await Collection<Mailbox>().ReplaceOneAsync(x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id, mailbox, new ReplaceOptions { IsUpsert = true });
|
||||||
|
public async Task SaveSync(Mailbox mailbox) => await Collection<Mailbox>().UpdateOneAsync(
|
||||||
|
x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id && x.ProtectedRefreshToken == mailbox.ProtectedRefreshToken,
|
||||||
|
Builders<Mailbox>.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<bool> TryLease(string id, string owner)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await Collection<WorkerLease>().FindOneAndUpdateAsync(x => x.Id == id && x.Until < DateTime.UtcNow,
|
||||||
|
Builders<WorkerLease>.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<WorkerLease>().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<string, string> rows = new();
|
||||||
|
private readonly object gate = new();
|
||||||
|
static string Key<T>(string id) => typeof(T).Name + ":" + id;
|
||||||
|
static T Clone<T>(string text) => System.Text.Json.JsonSerializer.Deserialize<T>(text)!;
|
||||||
|
static string Json<T>(T value) => System.Text.Json.JsonSerializer.Serialize(value);
|
||||||
|
public Task Initialize() => Task.CompletedTask;
|
||||||
|
public Task<List<T>> List<T>(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone<T>(x.Value)).Where(x => x.HotelId == hotel).ToList());
|
||||||
|
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => (await List<T>(hotel)).SingleOrDefault(x => x.Id == id);
|
||||||
|
public Task Insert<T>(T document) where T : TenantDocument { if (!rows.TryAdd(Key<T>(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; }
|
||||||
|
public Task<bool> Replace<T>(string hotel, string id, long version, T document) where T : TenantDocument
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
if (!rows.TryGetValue(Key<T>(id), out var raw)) return Task.FromResult(false);
|
||||||
|
var old = Clone<T>(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<T>(id)] = Json(document); return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public async Task Delete<T>(string hotel, string id) where T : TenantDocument { if (await Get<T>(hotel, id) != null) rows.TryRemove(Key<T>(id), out _); }
|
||||||
|
public Task<StaffUser?> FindLogin(string email) => Task.FromResult(rows.Where(x => x.Key.StartsWith("StaffUser:")).Select(x => Clone<StaffUser>(x.Value)).SingleOrDefault(x => x.Email == email));
|
||||||
|
public Task<OAuthRequest?> ConsumeOAuth(string id, string hotel, string user) { lock(gate) { var x = rows.TryGetValue(Key<OAuthRequest>(id), out var raw) ? Clone<OAuthRequest>(raw) : null; if(x?.HotelId != hotel || x.UserId != user || x.ExpiresAt <= DateTime.UtcNow) return Task.FromResult<OAuthRequest?>(null); rows.TryRemove(Key<OAuthRequest>(id),out _); return Task.FromResult<OAuthRequest?>(x); } }
|
||||||
|
public Task<List<Mailbox>> Mailboxes() => Task.FromResult(new List<Mailbox>());
|
||||||
|
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<bool> 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<Conversation>(message.HotelId)).Any(x => x.MailboxId == message.MailboxId && x.ProviderMessageId == message.ProviderMessageId)) await Insert(message); }
|
||||||
|
}
|
||||||
260
src/GuestOps.Core/AiExtractionPrompt.cs
Normal file
260
src/GuestOps.Core/AiExtractionPrompt.cs
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GuestOps.Models;
|
||||||
|
|
||||||
|
namespace GuestOps.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="BuildRowsFromJson"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class AiExtractionPrompt
|
||||||
|
{
|
||||||
|
/// <summary>Maximum email body characters embedded in the prompt.</summary>
|
||||||
|
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<ParsedBooking> BuildRowsFromJson(string raw, ParsedBooking seed)
|
||||||
|
{
|
||||||
|
var result = new List<ParsedBooking>();
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Common contract for any LLM that turns one email seed into 1+ rows.</summary>
|
||||||
|
public interface IAiExtractor
|
||||||
|
{
|
||||||
|
System.Threading.Tasks.Task<List<ParsedBooking>> ExtractAllAsync(
|
||||||
|
ParsedBooking seed, System.Threading.CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
23
src/GuestOps.Core/BookingValidation.cs
Normal file
23
src/GuestOps.Core/BookingValidation.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/GuestOps.Core/EmailBodyCleaner.cs
Normal file
73
src/GuestOps.Core/EmailBodyCleaner.cs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace GuestOps.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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)[^>]*>.*?</\1>", 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 <script>/<style> blocks then any remaining HTML tags.
|
||||||
|
if (s.Contains('<') && s.Contains('>'))
|
||||||
|
{
|
||||||
|
s = StyleScriptRx.Replace(s, " ");
|
||||||
|
s = HtmlTagRx.Replace(s, " ");
|
||||||
|
s = HtmlEntityRx.Replace(s, m => m.Groups[1].Value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"nbsp" => " ",
|
||||||
|
"amp" => "&",
|
||||||
|
"lt" => "<",
|
||||||
|
"gt" => ">",
|
||||||
|
"quot" => "\"",
|
||||||
|
_ => ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Drop forwarded-reply headers ("On X, Y wrote:") and the quoted lines that follow.
|
||||||
|
s = QuoteHeaderRx.Replace(s, "");
|
||||||
|
s = QuotedLineRx.Replace(s, "");
|
||||||
|
|
||||||
|
// 3. Strip standard signature blocks.
|
||||||
|
s = SignatureRx.Replace(s, "");
|
||||||
|
|
||||||
|
// 4. Whitespace tidy.
|
||||||
|
s = s.Replace("\r\n", "\n");
|
||||||
|
s = BlankLinesRx.Replace(s, "\n\n");
|
||||||
|
s = s.Trim();
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Truncate over-long bodies so we don't blow the LLM context window.</summary>
|
||||||
|
public static string Truncate(string body, int maxChars)
|
||||||
|
{
|
||||||
|
if (body.Length <= maxChars) return body;
|
||||||
|
return body[..maxChars] + "\n\n[...truncated, " + (body.Length - maxChars) + " more chars...]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1
src/GuestOps.Core/GuestOps.Core.csproj
Normal file
1
src/GuestOps.Core/GuestOps.Core.csproj
Normal file
@ -0,0 +1 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk" />
|
||||||
174
src/GuestOps.Core/ParsedBooking.cs
Normal file
174
src/GuestOps.Core/ParsedBooking.cs
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace GuestOps.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Amendment actions extracted from an email by the AI, or selectable manually.
|
||||||
|
/// Mirrors the `actions/*.ps1` scripts in the PowerShell original.
|
||||||
|
/// </summary>
|
||||||
|
public enum BookingAction
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
CreateBooking,
|
||||||
|
CancelBooking,
|
||||||
|
ChangeCheckIn,
|
||||||
|
ChangeRoomType,
|
||||||
|
ChangeRate,
|
||||||
|
AddPackage,
|
||||||
|
RemovePackage,
|
||||||
|
UpdateEmail,
|
||||||
|
UpdatePhone,
|
||||||
|
UpdateComment,
|
||||||
|
SendInvoice,
|
||||||
|
QuoteRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The status of a parsed booking as shown in the grid (drives row color).
|
||||||
|
/// </summary>
|
||||||
|
public enum RowStatus
|
||||||
|
{
|
||||||
|
New,
|
||||||
|
Reviewed,
|
||||||
|
Processing,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
Skipped,
|
||||||
|
LowConfidence,
|
||||||
|
Simulated,
|
||||||
|
NeedsReview
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One parsed email -> one (or several) candidate actions against Opera.
|
||||||
|
/// Bound to the DataGridView.
|
||||||
|
/// </summary>
|
||||||
|
public class ParsedBooking
|
||||||
|
{
|
||||||
|
public string ActionId { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
public bool ExtractionComplete { get; set; }
|
||||||
|
public int ExtractionAttempts { get; set; }
|
||||||
|
public bool NotificationsComplete { get; set; }
|
||||||
|
public string MailboxScope { get; set; } = "";
|
||||||
|
public uint ImapUid { get; set; }
|
||||||
|
public uint UidValidity { get; set; }
|
||||||
|
public string MessageId { get; set; } = "";
|
||||||
|
public string ReplyTo { get; set; } = "";
|
||||||
|
public string References { get; set; } = "";
|
||||||
|
public bool IsAutomaticMessage { get; set; }
|
||||||
|
public string QuoteId { get; set; } = "";
|
||||||
|
public string Currency { get; set; } = "";
|
||||||
|
|
||||||
|
// Grid / selection
|
||||||
|
public bool Selected { get; set; } = false;
|
||||||
|
public RowStatus Status { get; set; } = RowStatus.New;
|
||||||
|
public string StatusMessage{ get; set; } = "";
|
||||||
|
|
||||||
|
// Source email ---------------------------------------------------------
|
||||||
|
public string EmailUid { get; set; } = "";
|
||||||
|
public string EmailSubject { get; set; } = "";
|
||||||
|
public string EmailFrom { get; set; } = "";
|
||||||
|
public DateTime EmailDate { get; set; } = DateTime.MinValue;
|
||||||
|
public string EmailBody { get; set; } = "";
|
||||||
|
|
||||||
|
// AI extraction --------------------------------------------------------
|
||||||
|
public BookingAction Action { get; set; } = BookingAction.None;
|
||||||
|
public int Confidence { get; set; } = 0;
|
||||||
|
public string AiRationale { get; set; } = "";
|
||||||
|
|
||||||
|
// Opera identifiers ----------------------------------------------------
|
||||||
|
public string ConfirmationNumber { get; set; } = "";
|
||||||
|
public string ReservationId { get; set; } = ""; // OHIP resvId
|
||||||
|
public string ProfileId { get; set; } = "";
|
||||||
|
|
||||||
|
// Guest ----------------------------------------------------------------
|
||||||
|
public string GuestFirstName { get; set; } = "";
|
||||||
|
public string GuestLastName { get; set; } = "";
|
||||||
|
public string GuestEmail { get; set; } = "";
|
||||||
|
public string GuestPhone { get; set; } = "";
|
||||||
|
/// <summary>
|
||||||
|
/// Short loyalty badge string ("ALL/GOLD", "WYNDHAM/PLATINUM", etc.)
|
||||||
|
/// populated from a matched OHIP profile's memberships. Rendered as
|
||||||
|
/// the "Loyalty" column so operators can spot member stays at a glance.
|
||||||
|
/// Empty for non-member or unmatched guests.
|
||||||
|
/// </summary>
|
||||||
|
public string LoyaltyBadge { get; set; } = "";
|
||||||
|
|
||||||
|
// Stay -----------------------------------------------------------------
|
||||||
|
public DateTime? ArrivalDate { get; set; }
|
||||||
|
public DateTime? DepartureDate { get; set; }
|
||||||
|
public DateTime? OriginalCheckIn { get; set; } // for change-checkin actions
|
||||||
|
public int Nights { get; set; } = 1;
|
||||||
|
public int Rooms { get; set; } = 1;
|
||||||
|
public string RoomType { get; set; } = "";
|
||||||
|
public string RatePlanCode { get; set; } = "";
|
||||||
|
public decimal? RateAmount { get; set; }
|
||||||
|
public int Adults { get; set; } = 1;
|
||||||
|
public int Children { get; set; } = 0;
|
||||||
|
public string PackageCode { get; set; } = "";
|
||||||
|
public string Upsells { get; set; } = "";
|
||||||
|
public string PaymentGuarantee { get; set; } = "";
|
||||||
|
public string CancellationReason { get; set; } = "";
|
||||||
|
|
||||||
|
// Free-form
|
||||||
|
public string NewEmail { get; set; } = "";
|
||||||
|
public string NewPhone { get; set; } = "";
|
||||||
|
public string Comment { get; set; } = "";
|
||||||
|
|
||||||
|
public string ActionLabel => Action switch
|
||||||
|
{
|
||||||
|
BookingAction.CreateBooking => "Create",
|
||||||
|
BookingAction.CancelBooking => "Cancel",
|
||||||
|
BookingAction.ChangeCheckIn => "Change Check-in",
|
||||||
|
BookingAction.ChangeRoomType => "Change Room",
|
||||||
|
BookingAction.ChangeRate => "Change Rate",
|
||||||
|
BookingAction.AddPackage => "Add Package",
|
||||||
|
BookingAction.RemovePackage => "Remove Package",
|
||||||
|
BookingAction.UpdateEmail => "Update Email",
|
||||||
|
BookingAction.UpdatePhone => "Update Phone",
|
||||||
|
BookingAction.UpdateComment => "Update Comment",
|
||||||
|
BookingAction.SendInvoice => "Send Invoice",
|
||||||
|
BookingAction.QuoteRequest => "Send Quote",
|
||||||
|
_ => "(none)"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reservation snapshot returned from Opera (SOAP or REST).
|
||||||
|
/// </summary>
|
||||||
|
public class ReservationInfo
|
||||||
|
{
|
||||||
|
public string ConfirmationNumber { get; set; } = "";
|
||||||
|
public string ReservationId { get; set; } = "";
|
||||||
|
public string ProfileId { get; set; } = "";
|
||||||
|
public string Status { get; set; } = ""; // RESERVED / CHECKEDIN / CHECKEDOUT / CANCELED
|
||||||
|
public DateTime? ArrivalDate { get; set; }
|
||||||
|
public DateTime? DepartureDate { get; set; }
|
||||||
|
public string RoomType { get; set; } = "";
|
||||||
|
public string RatePlanCode { get; set; } = "";
|
||||||
|
public decimal? RateAmount { get; set; }
|
||||||
|
public string GuestFirstName { get; set; } = "";
|
||||||
|
public string GuestLastName { get; set; } = "";
|
||||||
|
public string PrimaryEmail { get; set; } = "";
|
||||||
|
public string PrimaryPhone { get; set; } = "";
|
||||||
|
public List<string> Packages { get; set; } = new();
|
||||||
|
public string RawPayload { get; set; } = ""; // for diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic result of a dispatcher call.
|
||||||
|
/// </summary>
|
||||||
|
public class OperaResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public bool IsSimulation { get; set; }
|
||||||
|
public bool RequiresReview { get; set; }
|
||||||
|
public static OperaResult Simulated(string message) => new() { IsSimulation = true, Message = message };
|
||||||
|
public static OperaResult Review(string message) => new() { RequiresReview = true, Message = message };
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public string RawResponse { get; set; } = "";
|
||||||
|
public static OperaResult Ok(string msg, string raw = "") => new() { Success = true, Message = msg, RawResponse = raw };
|
||||||
|
public static OperaResult Fail(string msg, string raw = "") => new() { Success = false, Message = msg, RawResponse = raw };
|
||||||
|
}
|
||||||
|
|
||||||
17
src/GuestOps.Core/SecretRedactor.cs
Normal file
17
src/GuestOps.Core/SecretRedactor.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace GuestOps.Services;
|
||||||
|
|
||||||
|
public static class SecretRedactor
|
||||||
|
{
|
||||||
|
private const string Keys = "access_token|refresh_token|id_token|client_secret|password|UserPassword|security_key|NmiSecurityKey|PlanetApiKey|OpenAiApiKey|OhipClientSecret|OhipAppKey|OwsPassword|ImapPassword|SmtpPassword|OhipPassword|HotelKeyPrivateKeyPem";
|
||||||
|
public static string Redact(string? value)
|
||||||
|
{
|
||||||
|
var text = value ?? "";
|
||||||
|
text = Regex.Replace(text, "(\"(?:" + Keys + ")\"\\s*:\\s*)\"(?:\\\\.|[^\"\\\\])*\"", "$1\"[REDACTED]\"", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, "(<(?:\\w+:)?(?:" + Keys + ")(?:\\s[^>]*)?>)[\\s\\S]*?(</(?:\\w+:)?(?:" + Keys + ")>)", "$1[REDACTED]$2", RegexOptions.IgnoreCase);
|
||||||
|
text = Regex.Replace(text, @"(?im)^(Authorization\s*:\s*).*$", "$1[REDACTED]");
|
||||||
|
text = Regex.Replace(text, @"-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----", "[REDACTED PRIVATE KEY]");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/GuestOps.Worker/GuestOps.Worker.csproj
Normal file
1
src/GuestOps.Worker/GuestOps.Worker.csproj
Normal file
@ -0,0 +1 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType></PropertyGroup><ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App"/><ProjectReference Include="../GuestOps.Api/GuestOps.Api.csproj"/></ItemGroup></Project>
|
||||||
48
src/GuestOps.Worker/Program.cs
Normal file
48
src/GuestOps.Worker/Program.cs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
using GuestOps.Web;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
builder.Logging.ClearProviders(); builder.Logging.AddConsole();
|
||||||
|
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
|
||||||
|
builder.Services.AddSingleton<IStore, MongoStore>();
|
||||||
|
var keyPath = builder.Configuration["Keys:Path"] ?? throw new InvalidOperationException("Keys:Path is required.");
|
||||||
|
builder.Services.AddDataProtection().SetApplicationName("GuestOps-Web").PersistKeysToFileSystem(new DirectoryInfo(keyPath));
|
||||||
|
builder.Services.AddHttpClient<GoogleMailbox>(c => c.Timeout = TimeSpan.FromSeconds(25));
|
||||||
|
builder.Services.AddHostedService<MailboxWorker>();
|
||||||
|
await builder.Build().RunAsync();
|
||||||
|
|
||||||
|
sealed class MailboxWorker(IStore store, IServiceScopeFactory factory, ILogger<MailboxWorker> log) : BackgroundService
|
||||||
|
{
|
||||||
|
readonly string owner = Guid.NewGuid().ToString("N");
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await store.Initialize();
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var mailbox in await store.Mailboxes())
|
||||||
|
{
|
||||||
|
if (!await store.TryLease(mailbox.Id, owner)) continue;
|
||||||
|
// Per-page deadline is shorter than the lease. Import is idempotent.
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); deadline.CancelAfter(TimeSpan.FromMinutes(2));
|
||||||
|
try { using var scope = factory.CreateScope(); await scope.ServiceProvider.GetRequiredService<GoogleMailbox>().Sync(mailbox, deadline.Token); }
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
log.LogWarning("Mailbox synchronization paused for {Id} ({Type})", mailbox.Id, ex.GetType().Name);
|
||||||
|
mailbox.SyncError = "Synchronization failed. Retry later or reconnect your Google mailbox.";
|
||||||
|
await store.SaveSync(mailbox);
|
||||||
|
}
|
||||||
|
finally { await store.ReleaseLease(mailbox.Id, owner); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (!stoppingToken.IsCancellationRequested) { log.LogWarning("Worker cycle paused ({Type})", ex.GetType().Name); }
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
tests/GuestOps.Tests/GuestOps.Tests.csproj
Normal file
1
tests/GuestOps.Tests/GuestOps.Tests.csproj
Normal file
@ -0,0 +1 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType></PropertyGroup><ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App"/><ProjectReference Include="../../src/GuestOps.Api/GuestOps.Api.csproj"/></ItemGroup></Project>
|
||||||
88
tests/GuestOps.Tests/Program.cs
Normal file
88
tests/GuestOps.Tests/Program.cs
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GuestOps.Web;
|
||||||
|
using GuestOps.Models;
|
||||||
|
using GuestOps.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using MongoDB.Driver;
|
||||||
|
|
||||||
|
int passed = 0;
|
||||||
|
void Check(string name, bool value) { if (!value) throw new Exception("FAIL " + name); Console.WriteLine("PASS " + name); passed++; }
|
||||||
|
var uri = Environment.GetEnvironmentVariable("MONGO_TEST_URI");
|
||||||
|
var dbName = "guestops_test_" + Guid.NewGuid().ToString("N");
|
||||||
|
IStore store = uri == null ? new PreviewStore() : new MongoStore(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build());
|
||||||
|
await store.Initialize();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var a = new Hotel { Name = "Hotel A" }; a.HotelId = a.Id;
|
||||||
|
var b = new Hotel { Name = "Hotel B" }; b.HotelId = b.Id;
|
||||||
|
await store.Insert(a); await store.Insert(b);
|
||||||
|
Check("Hotels cannot read each other's settings", await store.Get<Hotel>(b.Id, a.Id) == null);
|
||||||
|
var row = new Conversation { HotelId = a.Id, MailboxId = "mailbox", ProviderMessageId = "message-1", Subject = "Parking" };
|
||||||
|
await store.Import(row); await store.Import(row);
|
||||||
|
Check("Duplicate email import is idempotent", (await store.List<Conversation>(a.Id)).Count == 1);
|
||||||
|
Check("Inbox queries enforce hotel boundary", (await store.List<Conversation>(b.Id)).Count == 0);
|
||||||
|
var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 };
|
||||||
|
Check("Settings update succeeds with current version", await store.Replace(a.Id,a.Id,0,altered));
|
||||||
|
Check("Concurrent stale edit rejected", !await store.Replace(a.Id,a.Id,0,altered));
|
||||||
|
Check("Saved settings read back", (await store.Get<Hotel>(a.Id,a.Id))?.Name == "Updated");
|
||||||
|
await store.Delete<Conversation>(b.Id,row.Id);
|
||||||
|
Check("Other hotel cannot delete message", await store.Get<Conversation>(a.Id,row.Id) != null);
|
||||||
|
var state = new OAuthRequest { HotelId = a.Id, UserId = "owner", ExpiresAt = DateTime.UtcNow.AddMinutes(1) }; await store.Insert(state);
|
||||||
|
Check("OAuth state bound to hotel and user", await store.ConsumeOAuth(state.Id,b.Id,"owner") == null && await store.ConsumeOAuth(state.Id,a.Id,"intruder") == null);
|
||||||
|
Check("OAuth state accepted exactly once", await store.ConsumeOAuth(state.Id,a.Id,"owner") != null && await store.ConsumeOAuth(state.Id,a.Id,"owner") == null);
|
||||||
|
var expired = new OAuthRequest { HotelId = a.Id, UserId = "owner", ExpiresAt = DateTime.UtcNow.AddMinutes(-1) }; await store.Insert(expired);
|
||||||
|
Check("Expired OAuth state rejected", await store.ConsumeOAuth(expired.Id,a.Id,"owner") == null);
|
||||||
|
if (uri != null)
|
||||||
|
{
|
||||||
|
Check("Worker lease excludes a second worker", await store.TryLease("mailbox","one") && !await store.TryLease("mailbox","two"));
|
||||||
|
await store.ReleaseLease("mailbox","two"); Check("Only owner can release worker lease", !await store.TryLease("mailbox","two"));
|
||||||
|
await store.ReleaseLease("mailbox","one"); Check("Worker lease can be acquired after release", await store.TryLease("mailbox","two"));
|
||||||
|
var reloaded = new MongoStore(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Mongo:ConnectionString"] = uri, ["Mongo:Database"] = dbName }).Build());
|
||||||
|
Check("Settings survive new database client", (await reloaded.Get<Hotel>(a.Id,a.Id))?.Name == "Updated");
|
||||||
|
var mailbox = new Mailbox { HotelId = a.Id, Email = "hotel@example.invalid" }; await store.SaveMailbox(mailbox);
|
||||||
|
bool denied = false; try { await store.SaveMailbox(new Mailbox { HotelId = b.Id, Email = mailbox.Email }); } catch (MongoWriteException) { denied = true; }
|
||||||
|
Check("Mailbox cannot be connected to two hotels", denied);
|
||||||
|
var oldMailbox = await store.Get<Mailbox>(a.Id, mailbox.Id);
|
||||||
|
mailbox.ProtectedRefreshToken = "new-protected-token"; await store.SaveMailbox(mailbox);
|
||||||
|
oldMailbox!.SyncError = "stale worker failure"; await store.SaveSync(oldMailbox);
|
||||||
|
var newMailbox = await store.Get<Mailbox>(a.Id, mailbox.Id);
|
||||||
|
Check("Stale worker cannot overwrite reconnected credentials", newMailbox?.ProtectedRefreshToken == "new-protected-token" && newMailbox.SyncError == "");
|
||||||
|
}
|
||||||
|
var parsed = AiExtractionPrompt.BuildRowsFromJson("{\"action\":\"CreateBooking\",\"first_name\":\"Guest {test}\"}", new ParsedBooking());
|
||||||
|
Check("Migrated parser preserves braces in JSON strings", parsed.Count > 0 && parsed[0].GuestFirstName == "Guest {test}");
|
||||||
|
Check("Migrated booking validation rejects multiple rooms", BookingValidation.Stay(new ParsedBooking { Rooms = 2 }) != null);
|
||||||
|
Check("Migrated redactor removes OAuth secrets", !SecretRedactor.Redact("{\"refresh_token\":\"secret-value\"}").Contains("secret-value"));
|
||||||
|
|
||||||
|
var baseUrl = Environment.GetEnvironmentVariable("TEST_API_URL");
|
||||||
|
if (baseUrl != null)
|
||||||
|
{
|
||||||
|
using var one = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = false }) { BaseAddress = new Uri(baseUrl) };
|
||||||
|
using var two = new HttpClient(new HttpClientHandler { CookieContainer = new CookieContainer(), AllowAutoRedirect = false }) { BaseAddress = new Uri(baseUrl) };
|
||||||
|
async Task<JsonElement> Read(HttpClient h,string path) => JsonDocument.Parse(await h.GetStringAsync(path)).RootElement.Clone();
|
||||||
|
async Task SetCsrf(HttpClient h) { var s=await Read(h,"/api/session");h.DefaultRequestHeaders.Remove("X-CSRF-TOKEN");h.DefaultRequestHeaders.Add("X-CSRF-TOKEN",s.GetProperty("csrfToken").GetString()); }
|
||||||
|
Check("Anonymous inbox access blocked", (await one.GetAsync("/api/conversations")).StatusCode == HttpStatusCode.Unauthorized);
|
||||||
|
Check("Unsafe requests require CSRF token", (await one.PostAsJsonAsync("/api/preview/start",new {})).StatusCode == HttpStatusCode.BadRequest);
|
||||||
|
await SetCsrf(one); await SetCsrf(two);
|
||||||
|
Check("Preview creates signed-in workspace", (await one.PostAsJsonAsync("/api/preview/start",new {})).IsSuccessStatusCode);
|
||||||
|
await two.PostAsJsonAsync("/api/preview/start",new {}); await SetCsrf(one); await SetCsrf(two);
|
||||||
|
var h1=await Read(one,"/api/hotel");var h2=await Read(two,"/api/hotel");
|
||||||
|
Check("Different preview sessions have different hotels", h1.GetProperty("id").GetString()!=h2.GetProperty("id").GetString());
|
||||||
|
var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString();
|
||||||
|
Check("Guessed message ID cannot be edited across hotels", (await two.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="stolen",version=0})).StatusCode==HttpStatusCode.NotFound);
|
||||||
|
Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode);
|
||||||
|
Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict);
|
||||||
|
Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode);
|
||||||
|
var cookie=(await one.GetAsync("/api/session")).Headers;
|
||||||
|
Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true);
|
||||||
|
await one.PostAsJsonAsync("/api/auth/logout",new {});
|
||||||
|
Check("Logout removes inbox access", (await one.GetAsync("/api/conversations")).StatusCode==HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
Console.WriteLine($"{passed} checks passed. MongoDB integration: {uri!=null}; HTTP integration: {baseUrl!=null}.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// This suite owns only a fresh, randomly named database under a fixed test prefix.
|
||||||
|
if (uri != null && dbName.StartsWith("guestops_test_")) await new MongoClient(uri).DropDatabaseAsync(dbName);
|
||||||
|
}
|
||||||
1
web/index.html
Normal file
1
web/index.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="description" content="GuestOps hotel email workspace"/><title>GuestOps · Hotel inbox</title><link rel="icon" type="image/svg+xml" href="/favicon.svg"/></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
931
web/package-lock.json
generated
Normal file
931
web/package-lock.json
generated
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/package.json
Normal file
1
web/package.json
Normal file
@ -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"}}
|
||||||
1
web/public/favicon.svg
Normal file
1
web/public/favicon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect width="48" height="48" rx="14" fill="#214f46"/><path d="M33 15a13 13 0 1 0 3 18V24H24" fill="none" stroke="#f5f3ec" stroke-width="5" stroke-linecap="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 230 B |
18
web/src/api.ts
Normal file
18
web/src/api.ts
Normal file
@ -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<T>(path: string, method = 'GET', body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch('/api' + path, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||||
|
const 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>('/session'); csrf = value.csrfToken; return value; }
|
||||||
81
web/src/main.tsx
Normal file
81
web/src/main.tsx
Normal file
@ -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<string,string> = { NeedsAttention: 'Needs attention', DraftReady: 'Draft ready', Completed: 'Completed' };
|
||||||
|
const initials = (name: string) => name.replace(/<.*>/, '').trim().split(' ').filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase();
|
||||||
|
const 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<Session|null>(null), [error,setError] = useState(''), [notice,setNotice] = useState('');
|
||||||
|
const [hotel,setHotel] = useState<Hotel|null>(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false);
|
||||||
|
const [conversations,setConversations] = useState<Conversation[]>([]), [knowledge,setKnowledge] = useState<Knowledge[]>([]), [activity,setActivity] = useState<Activity[]>([]), [mailboxes,setMailboxes] = useState<Mailboxes>({ configured:false,items:[] });
|
||||||
|
const [loaded,setLoaded] = useState(false);
|
||||||
|
async function refresh() {
|
||||||
|
const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<Conversation[]>('/conversations'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]);
|
||||||
|
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<void>) { 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?<div className="alert" role="alert"><CircleHelp size={18}/><span>{error}</span><button className="icon-button" onClick={()=>setError('')} aria-label="Dismiss error"><X size={17}/></button></div>:null;
|
||||||
|
if(!auth) return <div className="loading"><span className="brand-mark">g</span><p>Opening your workspace…</p>{errorBox}</div>;
|
||||||
|
if(!auth.user) return <Login preview={auth.preview} onLogin={login} onPreview={preview} busy={busy} error={errorBox}/>;
|
||||||
|
const count=conversations.filter(c=>c.status!=='Completed').length;
|
||||||
|
return <div className="app-shell">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<a href="/inbox" aria-label="GuestOps inbox" className="brand" onClick={e=>{e.preventDefault();go('/inbox');}}><span className="brand-mark">g</span><span>guestops<span className="brand-dot">.</span></span></a>
|
||||||
|
<div className="hotel-switch"><span className="hotel-icon"><Building2 size={19}/></span><div><strong>{hotel?.name||'Your hotel'}</strong><small>Hotel workspace</small></div></div>
|
||||||
|
<div className="nav-label">WORKSPACE</div>
|
||||||
|
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings}].map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||||
|
<div className="sidebar-bottom"><div className="mode-card"><ShieldCheck size={20}/><strong>You're in control</strong><p>Replies stay as drafts until your team reviews them.</p></div><div className="profile"><span className="avatar profile-avatar">{initials(auth.user.name)}</span><div><strong>{auth.user.name}</strong><small>{auth.user.role==='Owner'?'Hotel owner':'Team member'}</small></div><button className="icon-button" onClick={logout} disabled={busy} aria-label="Sign out"><LogOut size={18}/></button></div></div>
|
||||||
|
</aside>
|
||||||
|
<main className="main">
|
||||||
|
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>Draft-only mode</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||||
|
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
||||||
|
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/inbox'?<InboxPage conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||||
|
</main>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
function PageHeading({eyebrow,title,text}:{eyebrow:string;title:string;text:string}) { return <div className="page-heading"><span className="eyebrow">{eyebrow}</span><h1>{title}</h1><p>{text}</p></div>; }
|
||||||
|
function Empty({title,text}:{title:string;text:string}) {return <div className="empty"><Mail size={34}/><h2>{title}</h2><p>{text}</p></div>;}
|
||||||
|
function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise<void>;onPreview:()=>Promise<void>;busy:boolean;error:React.ReactNode}){
|
||||||
|
const [email,setEmail]=useState(''),[password,setPassword]=useState('');
|
||||||
|
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>Welcome back</h1><p>Sign in to take care of your guests.</p>{error}<form onSubmit={e=>{e.preventDefault();onLogin(email,password);}}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><p className="small muted">Need access or help signing in? Contact your hotel administrator.</p>{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</div></section></div>;
|
||||||
|
}
|
||||||
|
type Run=(a:()=>Promise<void>)=>Promise<void>;
|
||||||
|
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<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
|
||||||
|
const filtered=conversations.filter(c=>(filter==='All'||c.status===filter)&&(c.subject+' '+c.from+' '+c.body).toLowerCase().includes(search.toLowerCase()));
|
||||||
|
const 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<Conversation>(`/conversations/${current.id}/draft`,'PUT',{draft,version:current.version}));notify('Draft saved. Nothing has been sent.');});}
|
||||||
|
async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});}
|
||||||
|
return <div className="inbox-page"><div className="inbox-heading"><PageHeading eyebrow="A warm welcome starts here" title="Your guest inbox" text={needs?`${needs} conversations need your attention. Let's make their day.`:'A little space to focus on your guests.'}/><div className="mini-stats"><div><strong>{needs}</strong><span>Need attention</span></div><div><strong>{ready}</strong><span>Drafts ready</span></div></div></div>
|
||||||
|
<div className="inbox-toolbar"><div className="tabs" role="group" aria-label="Filter conversations">{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=><button key={key} className={filter===key?'tab selected':'tab'} onClick={()=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setFilter(key);}}>{label}{key==='NeedsAttention'&&needs>0&&<span>{needs}</span>}</button>)}</div><label className="search"><Search size={17}/><input aria-label="Search conversations" placeholder="Search messages…" value={search} onChange={e=>setSearch(e.target.value)}/></label></div>
|
||||||
|
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
|
||||||
|
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><div className="below-draft"><span>Review your draft, then reply from your hotel mailbox.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
|
||||||
|
}
|
||||||
|
function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge[];canEdit:boolean;busy:boolean;run:Run;onUpdate:(k:Knowledge)=>void;notify:(s:string)=>void}){
|
||||||
|
const [edit,setEdit]=useState<Knowledge|null>(null),[search,setSearch]=useState('');
|
||||||
|
useEffect(()=>{if(!edit)return;const previous=document.activeElement;const key=(e:KeyboardEvent)=>{if(e.key==='Escape'){e.preventDefault();setEdit(null);}if(e.key==='Tab'){const nodes=Array.from(document.querySelectorAll<HTMLElement>('.modal button:not(:disabled),.modal input,.modal textarea,.modal select'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous instanceof HTMLElement)previous.focus();};},[edit?.id]);
|
||||||
|
async function save(e:React.FormEvent) {e.preventDefault();if(!edit)return;await run(async()=>{const result=await api<Knowledge>('/knowledge'+(edit.id?'/'+edit.id:''),edit.id?'PUT':'POST',edit);onUpdate(result);setEdit(null);notify('Hotel knowledge saved.');});}
|
||||||
|
return <div className="page"><div className="heading-row"><PageHeading eyebrow="Answers your team can trust" title="Hotel knowledge" text="Keep your policies and helpful answers in one place."/>{canEdit&&<button className="button primary" onClick={()=>setEdit({id:'',title:'',answer:'',keywords:'',category:'General',approved:false,version:0})}><Plus size={17}/>Add an answer</button>}</div><div className="knowledge-summary"><BookOpen size={23}/><div><strong>Your hotel's source of truth</strong><p>Approve answers before your team uses them in a reply. Keep changing details up to date.</p></div><span>{items.filter(x=>x.approved).length} approved</span></div><label className="search knowledge-search"><Search size={17}/><input placeholder="Find an answer…" aria-label="Search hotel knowledge" value={search} onChange={e=>setSearch(e.target.value)}/></label><div className="knowledge-grid">{items.filter(k=>(k.title+' '+k.answer).toLowerCase().includes(search.toLowerCase())).map(k=><article className="knowledge-card" key={k.id}><div><span className="category-label">{k.category}</span><span className={'status '+(k.approved?'Completed':'NeedsAttention')}>{k.approved?'Approved':'Not approved'}</span></div><h2>{k.title}</h2><p>{k.answer}</p>{canEdit&&<button className="button text" onClick={()=>setEdit({...k})}>Edit answer<ArrowUpRight size={15}/></button>}</article>)}</div>{!items.length&&<Empty title="What should guests know?" text="Start with check-in times, parking and breakfast information."/>}{edit&&<div className="modal-backdrop"><section className="modal" role="dialog" aria-modal="true" aria-labelledby="knowledge-title"><div className="modal-heading"><h2 id="knowledge-title">{edit.id?'Edit answer':'Add an answer'}</h2><button className="icon-button" onClick={()=>setEdit(null)} aria-label="Close editor"><X size={20}/></button></div><form onSubmit={save}><label>Title<input autoFocus value={edit.title} maxLength={120} minLength={2} required onChange={e=>setEdit({...edit,title:e.target.value})}/></label><label>Category<input value={edit.category} maxLength={50} required onChange={e=>setEdit({...edit,category:e.target.value})}/></label><label>Answer<textarea value={edit.answer} rows={6} maxLength={5000} minLength={2} required onChange={e=>setEdit({...edit,answer:e.target.value})}/></label><label>Helpful keywords<input value={edit.keywords} maxLength={300} placeholder="parking, car, arrival" onChange={e=>setEdit({...edit,keywords:e.target.value})}/></label><label className="checkbox-label"><input type="checkbox" checked={edit.approved} onChange={e=>setEdit({...edit,approved:e.target.checked})}/>Approved for staff to use</label><div className="form-actions"><button type="button" className="button secondary" onClick={()=>setEdit(null)}>Cancel</button><button className="button primary" disabled={busy}>Save answer</button></div></form></section></div>}</div>;
|
||||||
|
}
|
||||||
|
function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void}){
|
||||||
|
const [form,setForm]=useState(hotel);useEffect(()=>setForm(hotel),[hotel]);
|
||||||
|
const result=new URLSearchParams(location.search).get('google');
|
||||||
|
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><section className="settings-card"><div className="section-heading"><Mail size={21}/><div><h2>Connected mailbox</h2><p>Bring recent guest messages into your shared inbox.</p></div></div>{mailboxes.items.map(m=><div className="mailbox" key={m.id}><span className="google-mark">G</span><div><strong>{m.email}</strong><small>{m.syncError||(m.lastSyncAt?'Last synced '+date(m.lastSyncAt):'Waiting for first synchronization')}</small></div><span className="status Completed">{m.status}</span></div>)}{!mailboxes.items.length&&<p className="muted">No mailbox connected yet.</p>}<button className="button secondary" disabled={busy||!mailboxes.configured||!owner} onClick={()=>run(async()=>{const r=await api<{url:string}>('/integrations/google/connect','POST');location.assign(r.url);})}><span className="google-mark">G</span>{mailboxes.items.length?'Connect or reconnect Google':'Connect Google mailbox'}<ArrowUpRight size={16}/></button>{!mailboxes.configured&&<p className="small muted">{preview?'Real mailbox connections are unavailable in this sample workspace.':'Your administrator needs to configure Google connection before this is available.'}</p>}<div className="settings-note"><ShieldCheck size={17}/><span>Read-only connection. GuestOps does not send, delete or change your Google emails in this first release.</span></div></section><section className="settings-card"><div className="section-heading"><ShieldCheck size={21}/><div><h2>Reply controls</h2><p>Your team makes the final decision.</p></div><span className="status Completed">Draft only</span></div><p>Prepare and save replies here, then send from your hotel mailbox. Automatic sending is not enabled in this migration milestone.</p></section></div>;
|
||||||
|
}
|
||||||
|
createRoot(document.getElementById('root')!).render(<App/>);
|
||||||
9
web/src/style.css
Normal file
9
web/src/style.css
Normal file
File diff suppressed because one or more lines are too long
1
web/tsconfig.json
Normal file
1
web/tsconfig.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","jsx":"react-jsx","strict":true,"noEmit":true,"skipLibCheck":true,"allowImportingTsExtensions":true},"include":["src"]}
|
||||||
3
web/vite.config.ts
Normal file
3
web/vite.config.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({ plugins: [react()], server: { port: 5173, strictPort: true, proxy: { '/api': 'http://127.0.0.1:5180', '/health': 'http://127.0.0.1:5180' } } });
|
||||||
Loading…
x
Reference in New Issue
Block a user