using System.Collections.Concurrent; using MongoDB.Driver; namespace GuestOps.Web; // All application reads/writes require a server-derived hotel ID. Global access is // confined to authentication and the mailbox worker, not exposed as query parameters. public interface IStore { Task Initialize(); Task> List(string hotel) where T : TenantDocument; Task Get(string hotel, string id) where T : TenantDocument; Task Insert(T document) where T : TenantDocument; Task Replace(string hotel, string id, long version, T document) where T : TenantDocument; Task Delete(string hotel, string id) where T : TenantDocument; Task FindLogin(string email); Task ConsumeOAuth(string id, string hotel, string user); Task> Mailboxes(); Task SaveMailbox(Mailbox mailbox); Task SaveSync(Mailbox mailbox); Task TryLease(string id, string owner); Task ReleaseLease(string id, string owner); Task Import(Conversation message); Task> Deliveries(); } public sealed class MongoStore : IStore { public Task> Deliveries() => Collection().Find(x => x.Delivery != null && (x.Delivery.State == "Pending" || x.Delivery.State == "Sending")).SortBy(x => x.Delivery!.UpdatedAt).Limit(100).ToListAsync(); private readonly IMongoDatabase db; public MongoStore(IConfiguration config) { var connection = config["Mongo:ConnectionString"] ?? throw new InvalidOperationException("Mongo:ConnectionString is required."); var settings = MongoClientSettings.FromConnectionString(connection); settings.ServerSelectionTimeout = TimeSpan.FromSeconds(8); db = new MongoClient(settings).GetDatabase(config["Mongo:Database"] ?? "guestops"); } IMongoCollection Collection() => db.GetCollection(typeof(T).Name.ToLowerInvariant()); FilterDefinition Scope(string hotel) where T : TenantDocument { if (string.IsNullOrWhiteSpace(hotel)) throw new InvalidOperationException("Hotel scope required."); return Builders.Filter.Eq(x => x.HotelId, hotel); } public async Task Initialize() { await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Email), new() { Unique = true })); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.HotelId).Ascending(x => x.MailboxId).Ascending(x => x.ProviderMessageId), new() { Unique = true })); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.HotelId).Descending(x => x.ReceivedAt))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending("Delivery.State").Ascending("Delivery.UpdatedAt"))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.Email), new() { Unique = true })); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x => x.ExpiresAt), new() { ExpireAfter = TimeSpan.Zero })); } public Task> List(string hotel) where T : TenantDocument { var query = Collection().Find(Scope(hotel)); if (typeof(T) == typeof(Conversation)) query = query.Sort(Builders.Sort.Descending("ReceivedAt")); if (typeof(T) == typeof(Activity)) query = query.Sort(Builders.Sort.Descending("At")); return query.Limit(500).ToListAsync(); } public async Task Get(string hotel, string id) where T : TenantDocument => await Collection().Find(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync(); public Task Insert(T document) where T : TenantDocument { _ = Scope(document.HotelId); return Collection().InsertOneAsync(document); } public async Task Replace(string hotel, string id, long version, T document) where T : TenantDocument { if (document.HotelId != hotel || document.Id != id) throw new InvalidOperationException("Invalid document scope."); var result = await Collection().ReplaceOneAsync(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id) & Builders.Filter.Eq("Version", version), document); return result.ModifiedCount == 1; } public async Task Delete(string hotel, string id) where T : TenantDocument => await Collection().DeleteOneAsync(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id)); public async Task FindLogin(string email) => await Collection().Find(x => x.Email == email).FirstOrDefaultAsync(); public async Task ConsumeOAuth(string id, string hotel, string user) => await Collection().FindOneAndDeleteAsync(x => x.Id == id && x.HotelId == hotel && x.UserId == user && x.ExpiresAt > DateTime.UtcNow); public Task> Mailboxes() => Collection().Find(x => x.Status == "Connected").ToListAsync(); public async Task SaveMailbox(Mailbox mailbox) => await Collection().ReplaceOneAsync(x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id, mailbox, new ReplaceOptions { IsUpsert = true }); public async Task SaveSync(Mailbox mailbox) => await Collection().UpdateOneAsync( x => x.HotelId == mailbox.HotelId && x.Id == mailbox.Id && x.ProtectedRefreshToken == mailbox.ProtectedRefreshToken, Builders.Update.Set(x => x.LastSyncAt, mailbox.LastSyncAt).Set(x => x.SyncError, mailbox.SyncError) .Set(x => x.PageToken, mailbox.PageToken).Set(x => x.WindowStart, mailbox.WindowStart).Set(x => x.WindowEnd, mailbox.WindowEnd)); public async Task TryLease(string id, string owner) { try { var result = await Collection().FindOneAndUpdateAsync(x => x.Id == id && x.Until < DateTime.UtcNow, Builders.Update.Set(x => x.Owner, owner).Set(x => x.Until, DateTime.UtcNow.AddMinutes(5)), new() { IsUpsert = true, ReturnDocument = ReturnDocument.After }); return result.Owner == owner; } catch (MongoCommandException ex) when (ex.Code == 11000) { return false; } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { return false; } } public async Task ReleaseLease(string id, string owner) => await Collection().DeleteOneAsync(x => x.Id == id && x.Owner == owner); public async Task Import(Conversation message) { try { await Insert(message); } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { /* already durable */ } } } // Explicit Development-only preview store. Production never falls back to this. public sealed class PreviewStore : IStore { public Task> Deliveries() => Task.FromResult(rows.Where(x => x.Key.StartsWith("Conversation:")).Select(x => Clone(x.Value)).Where(x => x.Delivery?.State is "Pending" or "Sending").ToList()); private readonly ConcurrentDictionary rows = new(); private readonly object gate = new(); static string Key(string id) => typeof(T).Name + ":" + id; static T Clone(string text) => System.Text.Json.JsonSerializer.Deserialize(text)!; static string Json(T value) => System.Text.Json.JsonSerializer.Serialize(value); public Task Initialize() => Task.CompletedTask; public Task> List(string hotel) where T : TenantDocument => Task.FromResult(rows.Where(x => x.Key.StartsWith(typeof(T).Name + ":")).Select(x => Clone(x.Value)).Where(x => x.HotelId == hotel).ToList()); public async Task Get(string hotel, string id) where T : TenantDocument => (await List(hotel)).SingleOrDefault(x => x.Id == id); public Task Insert(T document) where T : TenantDocument { if (!rows.TryAdd(Key(document.Id), Json(document))) throw new InvalidOperationException("Duplicate document"); return Task.CompletedTask; } public Task Replace(string hotel, string id, long version, T document) where T : TenantDocument { lock (gate) { if (!rows.TryGetValue(Key(id), out var raw)) return Task.FromResult(false); var old = Clone(raw); if (old.HotelId != hotel || document.HotelId != hotel || document.Id != id || (long)typeof(T).GetProperty("Version")!.GetValue(old)! != version) return Task.FromResult(false); rows[Key(id)] = Json(document); return Task.FromResult(true); } } public async Task Delete(string hotel, string id) where T : TenantDocument { if (await Get(hotel, id) != null) rows.TryRemove(Key(id), out _); } public Task FindLogin(string email) => Task.FromResult(rows.Where(x => x.Key.StartsWith("StaffUser:")).Select(x => Clone(x.Value)).SingleOrDefault(x => x.Email == email)); public Task ConsumeOAuth(string id, string hotel, string user) { lock(gate) { var x = rows.TryGetValue(Key(id), out var raw) ? Clone(raw) : null; if(x?.HotelId != hotel || x.UserId != user || x.ExpiresAt <= DateTime.UtcNow) return Task.FromResult(null); rows.TryRemove(Key(id),out _); return Task.FromResult(x); } } public Task> Mailboxes() => Task.FromResult(new List()); public Task SaveMailbox(Mailbox mailbox) => throw new InvalidOperationException("Real mailbox connections are unavailable in preview mode."); public Task SaveSync(Mailbox mailbox) => throw new InvalidOperationException("Real mailbox connections are unavailable in preview mode."); public Task TryLease(string id, string owner) => Task.FromResult(false); public Task ReleaseLease(string id, string owner) => Task.CompletedTask; public async Task Import(Conversation message) { if (!(await List(message.HotelId)).Any(x => x.MailboxId == message.MailboxId && x.ProviderMessageId == message.ProviderMessageId)) await Insert(message); } }