Add shared workspace API
This commit is contained in:
parent
eee1d5945c
commit
c74d0e54fc
@ -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<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)
|
||||
{
|
||||
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<OtthonDbContext> options)
|
||||
public DbSet<RegisteredDevice> Devices => Set<RegisteredDevice>();
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<FoodLearningEvent> FoodLearningEvents => Set<FoodLearningEvent>();
|
||||
public DbSet<WorkspaceState> Workspaces => Set<WorkspaceState>();
|
||||
|
||||
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>().Property(product => product.Barcode).HasMaxLength(64);
|
||||
modelBuilder.Entity<Product>().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<string> 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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user