diff --git a/MILESTONES.md b/MILESTONES.md index 50e80ff..900e35d 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -41,7 +41,7 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i | 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 | Planned | Add safe pagination beyond 500 conversations, protect unsaved drafts across all navigation/filter/search paths, honour hotel timezones, and close agreed desktop-parity gaps. | +| 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. | | 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 diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 094c772..37ce2df 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,6 +10,7 @@ The reviewed candidate is now promoted into the local `main` history. It is not - Approval-controlled OHIP PMS and NMI payment workflows. - FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery. - No-send FAQ batch evaluation with false-positive and false-negative reporting before activation. +- Stable tenant-scoped inbox pagination beyond the former 500-message view, stronger unsaved-draft navigation guards, and hotel-timezone inbox timestamps. - Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling. These capabilities still require their separately documented provider, host and operational acceptance. Google, PMS and payment-provider acceptance is not established by local automated tests. diff --git a/src/GuestOps.Api/ConversationPaging.cs b/src/GuestOps.Api/ConversationPaging.cs new file mode 100644 index 0000000..e18988d --- /dev/null +++ b/src/GuestOps.Api/ConversationPaging.cs @@ -0,0 +1,19 @@ +using System.Text; +namespace GuestOps.Web; + +public sealed record ConversationPage(IReadOnlyList Items,string? NextCursor); +public static class ConversationPaging +{ + public static string Encode(Conversation item)=>Convert.ToBase64String(Encoding.UTF8.GetBytes(item.ReceivedAt.Ticks+"|"+item.Id)).TrimEnd('=').Replace('+','-').Replace('/','_'); + public static bool TryDecode(string? value,out DateTime at,out string id) + { + at=default;id="";if(string.IsNullOrEmpty(value))return true; + try + { + if(value.Length>160)return false;var raw=value.Replace('-','+').Replace('_','/');raw=raw.PadRight((raw.Length+3)/4*4,'='); + var parts=Encoding.UTF8.GetString(Convert.FromBase64String(raw)).Split('|'); + return parts.Length==2&&long.TryParse(parts[0],out var ticks)&&ticks>=DateTime.MinValue.Ticks&&ticks<=DateTime.MaxValue.Ticks&&System.Text.RegularExpressions.Regex.IsMatch(parts[1],"^[a-f0-9]{32}$")&&(at=new DateTime(ticks,DateTimeKind.Utc))!=default&&(id=parts[1]).Length>0; + } + catch{return false;} + } +} diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 527bb47..09a40f9 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -169,6 +169,7 @@ api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel); }).RequireAuthorization("Owner"); api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))); +api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."})); api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound()); api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) => { diff --git a/src/GuestOps.Api/Store.cs b/src/GuestOps.Api/Store.cs index 19b5c0c..5808baf 100644 --- a/src/GuestOps.Api/Store.cs +++ b/src/GuestOps.Api/Store.cs @@ -11,6 +11,7 @@ public interface IStore Task WorkerLastSeen(); Task RecordWorkerHeartbeat(); Task> List(string hotel) where T : TenantDocument; + Task ConversationPage(string hotel,DateTime? before,string beforeId,int limit); Task Get(string hotel, string id) where T : TenantDocument; Task Insert(T document) where T : TenantDocument; Task Replace(string hotel, string id, long version, T document) where T : TenantDocument; @@ -78,6 +79,12 @@ public sealed class MongoStore : IStore if (typeof(T) == typeof(PmsChange) || typeof(T) == typeof(PaymentRequest)) query = query.Sort(Builders.Sort.Descending("UpdatedAt")); return query.Limit(500).ToListAsync(); } + public async Task ConversationPage(string hotel,DateTime? before,string beforeId,int limit) + { + var filter=Scope(hotel);if(before!=null)filter&=Builders.Filter.Lt(x=>x.ReceivedAt,before.Value)|(Builders.Filter.Eq(x=>x.ReceivedAt,before.Value)&Builders.Filter.Lt(x=>x.Id,beforeId)); + var rows=await Collection().Find(filter).SortByDescending(x=>x.ReceivedAt).ThenByDescending(x=>x.Id).Limit(limit+1).ToListAsync(); + var more=rows.Count>limit;if(more)rows.RemoveAt(rows.Count-1);return new(rows,more?ConversationPaging.Encode(rows[^1]):null); + } public async Task Get(string hotel, string id) where T : TenantDocument => await Collection().Find(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync(); public Task Insert(T document) where T : TenantDocument { @@ -179,6 +186,11 @@ public sealed class PreviewStore : IStore static string Json(T value) => System.Text.Json.JsonSerializer.Serialize(value); public Task Initialize() => Task.CompletedTask; public Task> List(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone(x.Value)).Where(x => x.HotelId == hotel).ToList()); + public async Task ConversationPage(string hotel,DateTime? before,string beforeId,int limit) + { + var query=(await List(hotel)).OrderByDescending(x=>x.ReceivedAt).ThenByDescending(x=>x.Id).Where(x=>before==null||x.ReceivedAtlimit;if(more)query.RemoveAt(query.Count-1);return new(query,more?ConversationPaging.Encode(query[^1]):null); + } public async Task Get(string hotel, string id) where T : TenantDocument => (await List(hotel)).SingleOrDefault(x => x.Id == id); public Task Insert(T document) where T : TenantDocument { if (!rows.TryAdd(Key(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; } public Task Replace(string hotel, string id, long version, T document) where T : TenantDocument diff --git a/tests/GuestOps.Tests/Program.cs b/tests/GuestOps.Tests/Program.cs index 9f950f1..b2ac3ff 100644 --- a/tests/GuestOps.Tests/Program.cs +++ b/tests/GuestOps.Tests/Program.cs @@ -36,6 +36,11 @@ try await store.Import(row); await store.Import(row); Check("Duplicate email import is idempotent", (await store.List(a.Id)).Count == 1); Check("Inbox queries enforce hotel boundary", (await store.List(b.Id)).Count == 0); + var pagingHotel=new Hotel{Name="Paging hotel"};pagingHotel.HotelId=pagingHotel.Id;await store.Insert(pagingHotel);var pagingNow=DateTime.UtcNow;var pagingAt=new DateTime(pagingNow.Ticks-pagingNow.Ticks%TimeSpan.TicksPerMillisecond,DateTimeKind.Utc); + foreach(var id in new[]{"00000000000000000000000000000003","00000000000000000000000000000002","00000000000000000000000000000001"})await store.Insert(new Conversation{Id=id,HotelId=pagingHotel.Id,ReceivedAt=pagingAt,Subject=id}); + var firstPage=await store.ConversationPage(pagingHotel.Id,null,"",2);Check("Conversation page has stable bounded cursor",firstPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000003","00000000000000000000000000000002"})&&firstPage.NextCursor!=null); + Check("Conversation cursor round-trips safely",ConversationPaging.TryDecode(firstPage.NextCursor,out var pageAt,out var pageId)&&pageAt==pagingAt&&pageId=="00000000000000000000000000000002"&&!ConversationPaging.TryDecode("not-a-cursor",out _,out _)); + var secondPage=await store.ConversationPage(pagingHotel.Id,pageAt,pageId,2);Check("Conversation pagination has no overlap or tenant leakage",secondPage.Items.Select(x=>x.Id).SequenceEqual(new[]{"00000000000000000000000000000001"})&&secondPage.NextCursor==null); var altered = new Hotel { Id = a.Id, HotelId = a.Id, Name = "Updated", Version = 1 }; 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)); diff --git a/web/src/api.ts b/web/src/api.ts index 36fa9d1..9408435 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -2,6 +2,7 @@ export type User = { id: string; name: string; role: string; hotelId: string }; export type Session = { preview: boolean; csrfToken: string; user: User | null }; export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; autoReplyMode: string; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean }; export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null }; +export type 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 }[] }; diff --git a/web/src/main.tsx b/web/src/main.tsx index 5ab562e..ebd312a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,7 @@ 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, Banknote, Building2, ChevronRight } from 'lucide-react'; -import { api, session, type Session, type Hotel, type Conversation, type Knowledge, type Activity, type Mailboxes } from './api'; +import { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api'; import './style.css'; import { OperationsPage } from './OperationsPage'; import { MailboxPanel } from './MailboxPanel'; @@ -14,17 +14,19 @@ import { ReplyActions, ReplyControls } from './ReplyActions'; const labels: Record = { NeedsAttention: 'Needs attention', DraftReady: 'Draft ready', Completed: 'Completed' }; const initials = (name: string) => name.replace(/<.*>/, '').trim().split(' ').filter(Boolean).slice(0,2).map(x => x[0]).join('').toUpperCase(); const sender = (name: string) => name.replace(/<.*>/, '').replaceAll('"', '').trim(); -const date = (value: string) => new Date(value).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); +const date = (value: string,timeZone: string) => new Date(value).toLocaleString(undefined, { timeZone, day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); function App() { const [auth,setAuth] = useState(null), [error,setError] = useState(''), [notice,setNotice] = useState(''); const [hotel,setHotel] = useState(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false); const [conversations,setConversations] = useState([]), [knowledge,setKnowledge] = useState([]), [activity,setActivity] = useState([]), [mailboxes,setMailboxes] = useState({ configured:false,items:[] }); + const [conversationCursor,setConversationCursor]=useState(null); const [loaded,setLoaded] = useState(false); async function refresh() { - const [h,c,k,a,m] = await Promise.all([api('/hotel'),api('/conversations'),api('/knowledge'),api('/activity'),api('/mailboxes')]); - setHotel(h);setConversations(c);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true); + const [h,c,k,a,m] = await Promise.all([api('/hotel'),api('/conversations/page'),api('/knowledge'),api('/activity'),api('/mailboxes')]); + setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true); } + async function moreConversations(){if(!conversationCursor)return;const page=await api('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);} 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]); @@ -49,7 +51,7 @@ function App() {
Workspace{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}
{auth.preview&&Preview · sample data}{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}
{errorBox}{notice&&
{notice}
} - {!loaded?

Loading your hotel…

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

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>} + {!loaded?

Loading your hotel…

:page==='/health'?:page==='/team'?:page==='/setup'?:page==='/inbox'?run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?:page==='/payments'?:page==='/reservations'?:page==='/knowledge'?setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?
{activity.length?activity.map(a=>
{a.action}

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>}
; } @@ -60,7 +62,7 @@ function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:( return
gguestops.
A little more time for your guests

Great hospitality.
A calmer inbox.

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

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

Welcome back

Sign in to take care of your guests.

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

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

{preview&&
Explore the interface with sample conversations.Preview changes are temporary. No real emails are sent.
}
; } type Run=(a:()=>Promise)=>Promise; -function InboxPage({hotel,mailboxes,conversations,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){ +function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){ const [filter,setFilter]=useState('All'),[search,setSearch]=useState(''),[selected,setSelected]=useState(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]; @@ -71,9 +73,9 @@ function InboxPage({hotel,mailboxes,conversations,knowledge,busy,run,onUpdate,no async function save() {if(!current)return;await run(async()=>{onUpdate(await api(`/conversations/${current.id}/draft`,'PUT',{draft,version:current.version}));notify('Draft saved. Nothing has been sent.');});} async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});} return
{needs}Need attention
{ready}Drafts ready
-
{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=>)}
- {!conversations.length?
:
{filtered.length} conversation{filtered.length===1?'':'s'}Newest first
{filtered.map((c,i)=>)}{!filtered.length&&}
-
{current?<>
{current.category}

{current.subject}

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