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();
|
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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user