From c74d0e54fccaf1f6d8f7b32a047ef25435bf276d Mon Sep 17 00:00:00 2001 From: zsolt Date: Thu, 13 Aug 2026 00:51:16 +0200 Subject: [PATCH] Add shared workspace API --- OtthonApi/Program.cs | 143 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/OtthonApi/Program.cs b/OtthonApi/Program.cs index 65f3707..92dbfcc 100644 --- a/OtthonApi/Program.cs +++ b/OtthonApi/Program.cs @@ -465,6 +465,76 @@ app.MapGet("/api/shopping/recommendations", async ( }); }); +app.MapGet("/api/workspace/state", async ( + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var token = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(token)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + + var workspace = await GetOrCreateWorkspace(db, token); + return Results.Ok(new WorkspaceStateResponse( + workspace.Token, + workspace.StateJson, + workspace.UpdatedUtc)); +}); + +app.MapPut("/api/workspace/state", async ( + HttpContext context, + OtthonDbContext db) => +{ + if (!HasApiToken(context)) + { + return Results.Unauthorized(); + } + + var token = GetWorkspaceToken(context); + if (string.IsNullOrWhiteSpace(token)) + { + return Results.BadRequest(new { message = "Missing X-Workspace-Token header." }); + } + + string? stateJson; + try + { + using var bodyDocument = await JsonDocument.ParseAsync(context.Request.Body); + var root = bodyDocument.RootElement; + stateJson = root.TryGetProperty("stateJson", out var stateProperty) + && stateProperty.ValueKind == JsonValueKind.String + ? stateProperty.GetString()?.Trim() + : root.GetRawText(); + + if (string.IsNullOrWhiteSpace(stateJson)) + { + return Results.BadRequest(new { message = "Workspace state cannot be empty." }); + } + + using var _ = JsonDocument.Parse(stateJson); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { + return Results.BadRequest(new { message = "Invalid workspace state JSON. Send UTF-8 encoded JSON." }); + } + + var workspace = await GetOrCreateWorkspace(db, token); + workspace.StateJson = stateJson; + workspace.UpdatedUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(new WorkspaceStateResponse( + workspace.Token, + workspace.StateJson, + workspace.UpdatedUtc)); +}); + app.Run(); static bool HasApiToken(HttpContext context) @@ -496,6 +566,43 @@ static string? Clean(string? value) return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } +static string? GetWorkspaceToken(HttpContext context) +{ + if (context.Request.Headers.TryGetValue("X-Workspace-Token", out var headerToken)) + { + return Clean(headerToken.ToString()); + } + + if (context.Request.Query.TryGetValue("workspaceToken", out var queryToken)) + { + return Clean(queryToken.ToString()); + } + + return null; +} + +static async Task GetOrCreateWorkspace( + OtthonDbContext db, + string token) +{ + var workspace = await db.Workspaces.FindAsync(token); + if (workspace is not null) + { + return workspace; + } + + workspace = new WorkspaceState + { + Token = token, + StateJson = "{}", + CreatedUtc = DateTimeOffset.UtcNow, + UpdatedUtc = DateTimeOffset.UtcNow + }; + db.Workspaces.Add(workspace); + await db.SaveChangesAsync(); + return workspace; +} + static void ApplyProductLookup(Product target, Product lookup, string source) { target.Name = lookup.Name; @@ -580,6 +687,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await db.Database.EnsureCreatedAsync(); await EnsureProductSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db); + await EnsureWorkspaceSchemaAsync(db); return; } catch (Exception ex) when (attempt < maxAttempts) @@ -596,6 +704,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger) await db.Database.EnsureCreatedAsync(); await EnsureProductSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db); + await EnsureWorkspaceSchemaAsync(db); } static async Task EnsureProductSchemaAsync(OtthonDbContext db) @@ -615,6 +724,22 @@ IF COL_LENGTH('Products', 'IngredientsText') IS NULL await db.Database.ExecuteSqlRawAsync(sql); } +static async Task EnsureWorkspaceSchemaAsync(OtthonDbContext db) +{ + var sql = """ +IF OBJECT_ID('Workspaces', 'U') IS NULL +BEGIN + CREATE TABLE Workspaces ( + Token nvarchar(160) NOT NULL CONSTRAINT PK_Workspaces PRIMARY KEY, + StateJson nvarchar(max) NOT NULL, + CreatedUtc datetimeoffset NOT NULL, + UpdatedUtc datetimeoffset NOT NULL + ); +END +"""; + await db.Database.ExecuteSqlRawAsync(sql); +} + static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db) { var sql = """ @@ -843,9 +968,12 @@ public sealed class OtthonDbContext(DbContextOptions options) public DbSet Devices => Set(); public DbSet Products => Set(); public DbSet FoodLearningEvents => Set(); + public DbSet Workspaces => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasKey(workspace => workspace.Token); + modelBuilder.Entity().Property(workspace => workspace.Token).HasMaxLength(160); modelBuilder.Entity().HasKey(product => product.Barcode); modelBuilder.Entity().Property(product => product.Barcode).HasMaxLength(64); modelBuilder.Entity().Property(product => product.Name).HasMaxLength(300); @@ -910,6 +1038,14 @@ public sealed class FoodLearningEvent public DateTimeOffset CreatedUtc { get; set; } } +public sealed class WorkspaceState +{ + public required string Token { get; set; } + public required string StateJson { get; set; } + public DateTimeOffset CreatedUtc { get; set; } + public DateTimeOffset UpdatedUtc { get; set; } +} + public sealed record LoginForm(string Password); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); public sealed record ProductUpsertRequest( @@ -973,6 +1109,13 @@ public sealed record ShoppingRecommendationResponse( DateTimeOffset LastSeenUtc, IReadOnlyList Reasons); +public sealed record WorkspaceStateRequest(string StateJson); + +public sealed record WorkspaceStateResponse( + string WorkspaceToken, + string StateJson, + DateTimeOffset UpdatedUtc); + public sealed record ProductResponse( bool Found, string Barcode,