milestone 16 complete
Some checks are pending
Build and verify web migration / verify (push) Waiting to run

This commit is contained in:
wolf-demon 2026-09-29 20:40:18 +01:00
parent 47178a64ce
commit 92f875621d
13 changed files with 96 additions and 19 deletions

4
.gitignore vendored
View File

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

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

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

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

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

View File

@ -40,8 +40,8 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
| 13 | Rezlynx/Guestline adapter | C | Planned | Obtain the provider contract and sandbox, implement the adapter and mapping, and accept idempotency, stale-data, ambiguous-write, and reconciliation paths. |
| 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. |
| 15 | Knowledge, AI, and FAQ activation | B | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. |
| 16 | Identity, preferences, and privacy | B/C | Planned | Finish operational identity controls, privacy/retention decisions, hotel preferences, audit review, and proxy-aware login rate limiting. |
| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Complete hotel-timezone rendering and agreed desktop-parity acceptance. |
| 16 | Identity, preferences, and privacy | B/C | In progress | Login throttling now uses the client address only after one-hop processing from the explicitly trusted reverse proxy. Finish privacy/retention decisions, preference coverage, identity acceptance and audit review. |
| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Inbox, activity, mailbox-health and FAQ-history timestamps use the saved hotel timezone; finish the remaining secondary screens and agreed desktop-parity acceptance. |
| 18 | Pilot, capacity, and release approval | B/C | Planned | Run the supervised pilot, exercise support and incident procedures, validate capacity, resolve pilot findings, and capture explicit go/no-go approval for wider rollout. |
## Delivery sequence

View File

@ -54,7 +54,7 @@ Merge `deploy/nginx.conf` into the existing host configuration, check with `ngin
Compose reserves the private bridge `172.30.87.0/24`, with gateway `172.30.87.1`. The API trusts the host gateway's `X-Forwarded-Proto` header so Nginx's HTTPS connections receive secure session and CSRF cookies. Check for an existing network using that range. If it conflicts, set both `GUESTOPS_SUBNET` and `GUESTOPS_GATEWAY` in `.env` to a free matching subnet and gateway. Do not replace this with unrestricted forwarded-header trust.
The initial rate limiter keys off the direct peer address. Behind this loopback reverse proxy it is shared across users (10 login attempts/minute), which is conservative for a pilot. Before scaling, configure explicitly trusted forwarded headers and per-account/IP rate limits; never trust arbitrary client-supplied forwarding headers.
The login rate limiter uses the client address forwarded by the explicitly configured host proxy (10 attempts per client address per minute). ASP.NET accepts one forwarding hop only from `Proxy__KnownAddress`; arbitrary client-supplied forwarding headers are not trusted. Keep the API port loopback-only and update the known address together with any reviewed Compose subnet change.
## 5. Configure Google

View File

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

View File

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

View File

@ -93,14 +93,14 @@ try
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 health=await Read(one,"/api/operations");
Check("Health overview is hotel scoped and labels preview worker",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview");
Check("Health overview is hotel scoped and carries hotel timezone",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview"&&health.GetProperty("timeZone").GetString()=="Europe/London");
var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString();
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 boxes=await Read(one,"/api/mailboxes");var boxId=boxes.GetProperty("items")[0].GetProperty("id").GetString();
Check("Mailbox health includes recovery state without secrets",boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken"));
Check("Mailbox health includes timezone and recovery state without secrets",boxes.GetProperty("timeZone").GetString()=="Europe/London"&&boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken"));
foreach(var action in new[]{"disconnect","reconnect","retry"})Check("Other hotel cannot "+action+" a mailbox",(await two.PostAsJsonAsync($"/api/mailboxes/{boxId}/{action}",new{version=0})).StatusCode==HttpStatusCode.NotFound);
Check("Preview recovery cannot change real Google state",(await one.PostAsJsonAsync($"/api/mailboxes/{boxId}/reconnect",new{version=0})).StatusCode==HttpStatusCode.BadRequest);
Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest);

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ export type Conversation = { id: string; from: string; subject: string; body: st
export type ConversationPage = { items: Conversation[]; nextCursor: string | null };
export type Knowledge = { id: string; title: string; category: string; answer: string; keywords: string; approved: boolean; version: number };
export type Activity = { id: string; at: string; userName: string; action: string };
export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; items: { id: string; email: string; status: string; version: number; lastSyncAt: string | null; lastAttemptAt: string | null; nextAttemptAt: string | null; failureCount: number; syncErrorCode: string; catchingUp: boolean; syncError: string; canSend: boolean }[] };
export type Mailboxes = { configured: boolean; aiConfigured?: boolean; sendingConfigured?: boolean; timeZone?: string; items: { id: string; email: string; status: string; version: number; lastSyncAt: string | null; lastAttemptAt: string | null; nextAttemptAt: string | null; failureCount: number; syncErrorCode: string; catchingUp: boolean; syncError: string; canSend: boolean }[] };
let csrf = '';
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) });

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

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