Add shared workspace API

This commit is contained in:
zsolt 2026-08-13 00:51:16 +02:00
parent eee1d5945c
commit c74d0e54fc

View File

@ -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(); app.Run();
static bool HasApiToken(HttpContext context) static bool HasApiToken(HttpContext context)
@ -496,6 +566,43 @@ static string? Clean(string? value)
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; 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<WorkspaceState> 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) static void ApplyProductLookup(Product target, Product lookup, string source)
{ {
target.Name = lookup.Name; target.Name = lookup.Name;
@ -580,6 +687,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await EnsureProductSchemaAsync(db); await EnsureProductSchemaAsync(db);
await EnsureFoodLearningSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db);
await EnsureWorkspaceSchemaAsync(db);
return; return;
} }
catch (Exception ex) when (attempt < maxAttempts) catch (Exception ex) when (attempt < maxAttempts)
@ -596,6 +704,7 @@ static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
await db.Database.EnsureCreatedAsync(); await db.Database.EnsureCreatedAsync();
await EnsureProductSchemaAsync(db); await EnsureProductSchemaAsync(db);
await EnsureFoodLearningSchemaAsync(db); await EnsureFoodLearningSchemaAsync(db);
await EnsureWorkspaceSchemaAsync(db);
} }
static async Task EnsureProductSchemaAsync(OtthonDbContext db) static async Task EnsureProductSchemaAsync(OtthonDbContext db)
@ -615,6 +724,22 @@ IF COL_LENGTH('Products', 'IngredientsText') IS NULL
await db.Database.ExecuteSqlRawAsync(sql); 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) static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
{ {
var sql = """ var sql = """
@ -843,9 +968,12 @@ public sealed class OtthonDbContext(DbContextOptions<OtthonDbContext> options)
public DbSet<RegisteredDevice> Devices => Set<RegisteredDevice>(); public DbSet<RegisteredDevice> Devices => Set<RegisteredDevice>();
public DbSet<Product> Products => Set<Product>(); public DbSet<Product> Products => Set<Product>();
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>(); public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<WorkspaceState>().HasKey(workspace => workspace.Token);
modelBuilder.Entity<WorkspaceState>().Property(workspace => workspace.Token).HasMaxLength(160);
modelBuilder.Entity<Product>().HasKey(product => product.Barcode); modelBuilder.Entity<Product>().HasKey(product => product.Barcode);
modelBuilder.Entity<Product>().Property(product => product.Barcode).HasMaxLength(64); modelBuilder.Entity<Product>().Property(product => product.Barcode).HasMaxLength(64);
modelBuilder.Entity<Product>().Property(product => product.Name).HasMaxLength(300); modelBuilder.Entity<Product>().Property(product => product.Name).HasMaxLength(300);
@ -910,6 +1038,14 @@ public sealed class FoodLearningEvent
public DateTimeOffset CreatedUtc { get; set; } 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 LoginForm(string Password);
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform); public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
public sealed record ProductUpsertRequest( public sealed record ProductUpsertRequest(
@ -973,6 +1109,13 @@ public sealed record ShoppingRecommendationResponse(
DateTimeOffset LastSeenUtc, DateTimeOffset LastSeenUtc,
IReadOnlyList<string> Reasons); IReadOnlyList<string> Reasons);
public sealed record WorkspaceStateRequest(string StateJson);
public sealed record WorkspaceStateResponse(
string WorkspaceToken,
string StateJson,
DateTimeOffset UpdatedUtc);
public sealed record ProductResponse( public sealed record ProductResponse(
bool Found, bool Found,
string Barcode, string Barcode,