1289 lines
41 KiB
C#
1289 lines
41 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Services.AddOpenApi();
|
|
builder.Services.AddHttpClient("open-food-facts", client =>
|
|
{
|
|
client.BaseAddress = new Uri("https://world.openfoodfacts.org/");
|
|
client.DefaultRequestHeaders.UserAgent.Add(
|
|
new ProductInfoHeaderValue("JarvisApi", "0.1"));
|
|
});
|
|
|
|
builder.Services.AddDbContext<OtthonDbContext>(options =>
|
|
{
|
|
var connectionString = builder.Configuration.GetConnectionString("Default")
|
|
?? "Server=localhost,14333;Database=JarvisHome;User Id=sa;Password=Jarvis!2026Api;TrustServerCertificate=True;Encrypt=False";
|
|
options.UseSqlServer(connectionString);
|
|
});
|
|
|
|
builder.Services
|
|
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
.AddCookie(options =>
|
|
{
|
|
options.LoginPath = "/login";
|
|
options.LogoutPath = "/logout";
|
|
options.Cookie.Name = "jarvis_admin";
|
|
});
|
|
builder.Services.AddAuthorization();
|
|
|
|
var app = builder.Build();
|
|
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var db = scope.ServiceProvider.GetRequiredService<OtthonDbContext>();
|
|
var logger = scope.ServiceProvider
|
|
.GetRequiredService<ILoggerFactory>()
|
|
.CreateLogger("JarvisStartup");
|
|
await EnsureDatabaseReady(db, logger);
|
|
}
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi();
|
|
}
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapGet("/health", () => Results.Ok(new
|
|
{
|
|
status = "ok",
|
|
utc = DateTimeOffset.UtcNow
|
|
}));
|
|
|
|
app.MapGet("/", () => Results.Redirect("/devices"));
|
|
|
|
app.MapGet("/login", () => Results.Content(LoginPage(), "text/html; charset=utf-8"));
|
|
|
|
app.MapPost("/login", async (
|
|
HttpContext context,
|
|
[FromForm] LoginForm form,
|
|
IConfiguration config) =>
|
|
{
|
|
var expected = config["OTTHON_ADMIN_PASSWORD"] ?? "change-me-admin";
|
|
if (form.Password != expected)
|
|
{
|
|
return Results.Content(LoginPage("Invalid password."), "text/html; charset=utf-8");
|
|
}
|
|
|
|
var claims = new[] { new Claim(ClaimTypes.Name, "admin") };
|
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
|
await context.SignInAsync(
|
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
|
new ClaimsPrincipal(identity));
|
|
|
|
return Results.Redirect("/devices");
|
|
}).DisableAntiforgery();
|
|
|
|
app.MapPost("/logout", async (HttpContext context) =>
|
|
{
|
|
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
return Results.Redirect("/login");
|
|
}).RequireAuthorization();
|
|
|
|
app.MapGet("/devices", async (OtthonDbContext db) =>
|
|
{
|
|
var devices = await db.Devices
|
|
.OrderByDescending(device => device.LastSeenUtc ?? device.CreatedUtc)
|
|
.ToListAsync();
|
|
return Results.Content(DevicesPage(devices), "text/html; charset=utf-8");
|
|
}).RequireAuthorization();
|
|
|
|
app.MapPost("/devices/{id:int}/deactivate", async (int id, OtthonDbContext db) =>
|
|
{
|
|
var device = await db.Devices.FindAsync(id);
|
|
if (device is not null)
|
|
{
|
|
device.IsActive = false;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
return Results.Redirect("/devices");
|
|
}).RequireAuthorization().DisableAntiforgery();
|
|
|
|
app.MapPost("/devices/{id:int}/approve", async (int id, OtthonDbContext db) =>
|
|
{
|
|
var device = await db.Devices.FindAsync(id);
|
|
if (device is not null)
|
|
{
|
|
device.IsActive = true;
|
|
device.LastSeenUtc = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
return Results.Redirect("/devices");
|
|
}).RequireAuthorization().DisableAntiforgery();
|
|
|
|
app.MapGet("/settings", async (OtthonDbContext db) =>
|
|
{
|
|
var googleClientId = await GetSetting(db, GoogleCalendarServerClientIdSettingKey()) ?? "";
|
|
return Results.Content(SettingsPage(googleClientId), "text/html; charset=utf-8");
|
|
}).RequireAuthorization();
|
|
|
|
app.MapPost("/settings/google-calendar", async (
|
|
[FromForm] GoogleCalendarSettingsForm form,
|
|
OtthonDbContext db) =>
|
|
{
|
|
await SetSetting(
|
|
db,
|
|
GoogleCalendarServerClientIdSettingKey(),
|
|
Clean(form.ServerClientId) ?? "");
|
|
return Results.Redirect("/settings");
|
|
}).RequireAuthorization().DisableAntiforgery();
|
|
|
|
app.MapGet("/api/app-config", async (HttpContext context, OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var googleClientId = await GetSetting(db, GoogleCalendarServerClientIdSettingKey());
|
|
return Results.Ok(new AppConfigResponse(
|
|
googleClientId,
|
|
!string.IsNullOrWhiteSpace(googleClientId)));
|
|
});
|
|
|
|
app.MapPost("/api/devices/register", async (
|
|
DeviceRegistrationRequest request,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var device = new RegisteredDevice
|
|
{
|
|
Name = string.IsNullOrWhiteSpace(request.DeviceName)
|
|
? "Tablet"
|
|
: request.DeviceName.Trim(),
|
|
Platform = string.IsNullOrWhiteSpace(request.Platform)
|
|
? "unknown"
|
|
: request.Platform.Trim(),
|
|
DeviceToken = Guid.NewGuid().ToString("N"),
|
|
CreatedUtc = DateTimeOffset.UtcNow,
|
|
LastSeenUtc = null,
|
|
IsActive = false
|
|
};
|
|
|
|
db.Devices.Add(device);
|
|
await db.SaveChangesAsync();
|
|
return Results.Ok(new
|
|
{
|
|
device.Id,
|
|
device.Name,
|
|
device.Platform,
|
|
device.DeviceToken,
|
|
status = "pending"
|
|
});
|
|
});
|
|
|
|
app.MapGet("/api/devices/{id:int}/status", async (
|
|
int id,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var device = await db.Devices.FindAsync(id);
|
|
if (device is null)
|
|
{
|
|
return Results.Ok(new { found = false, status = "notRegistered" });
|
|
}
|
|
|
|
return Results.Ok(new
|
|
{
|
|
found = true,
|
|
device.Id,
|
|
device.Name,
|
|
status = device.IsActive ? "approved" : "pending"
|
|
});
|
|
});
|
|
|
|
app.MapGet("/api/products/{barcode}", async (
|
|
string barcode,
|
|
bool? refresh,
|
|
HttpContext context,
|
|
OtthonDbContext db,
|
|
IHttpClientFactory httpClientFactory,
|
|
ILoggerFactory loggerFactory) =>
|
|
{
|
|
var logger = loggerFactory.CreateLogger("ProductLookup");
|
|
if (!HasApiToken(context))
|
|
{
|
|
logger.LogWarning("Unauthorized product lookup for raw barcode {Barcode}.", barcode);
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var normalized = NormalizeBarcode(barcode);
|
|
if (string.IsNullOrWhiteSpace(normalized))
|
|
{
|
|
return Results.BadRequest(new { message = "Missing barcode." });
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Product lookup started. RawBarcode={RawBarcode}, Barcode={Barcode}, Refresh={Refresh}.",
|
|
barcode,
|
|
normalized,
|
|
refresh == true);
|
|
|
|
var cached = await db.Products.FindAsync(normalized);
|
|
if (cached is not null && refresh != true)
|
|
{
|
|
logger.LogInformation(
|
|
"Product lookup cache hit. Barcode={Barcode}, Name={Name}.",
|
|
normalized,
|
|
cached.Name);
|
|
return Results.Ok(ProductResponse.From(cached, true));
|
|
}
|
|
|
|
var lookup = await TryFetchProductFromOpenFoodFacts(normalized, httpClientFactory);
|
|
if (lookup is null)
|
|
{
|
|
logger.LogInformation(
|
|
"Product lookup miss. Barcode={Barcode}, Cached={Cached}.",
|
|
normalized,
|
|
cached is not null);
|
|
if (cached is not null)
|
|
{
|
|
return Results.Ok(ProductResponse.From(cached, true, "External refresh failed; returning cached product."));
|
|
}
|
|
|
|
return Results.NotFound(new
|
|
{
|
|
found = false,
|
|
barcode = normalized,
|
|
source = "openfoodfacts",
|
|
message = "Product was not found locally or in Open Food Facts."
|
|
});
|
|
}
|
|
|
|
var product = cached ?? lookup;
|
|
ApplyProductLookup(product, lookup, cached is null ? "openfoodfacts" : "openfoodfacts-refresh");
|
|
if (cached is null)
|
|
{
|
|
db.Products.Add(product);
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
logger.LogInformation(
|
|
"Product lookup saved. Barcode={Barcode}, Name={Name}, Source={Source}.",
|
|
normalized,
|
|
product.Name,
|
|
product.Source);
|
|
return Results.Ok(ProductResponse.From(product, false));
|
|
});
|
|
|
|
app.MapGet("/api/products", async (
|
|
string? query,
|
|
int? take,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var limit = Math.Clamp(take ?? 50, 1, 200);
|
|
var products = db.Products.AsNoTracking();
|
|
if (!string.IsNullOrWhiteSpace(query))
|
|
{
|
|
var term = query.Trim();
|
|
products = products.Where(product =>
|
|
product.Barcode.Contains(term)
|
|
|| product.Name.Contains(term)
|
|
|| (product.Brand != null && product.Brand.Contains(term)));
|
|
}
|
|
|
|
var rows = await products
|
|
.OrderByDescending(product => product.LastLookupUtc)
|
|
.Take(limit)
|
|
.ToListAsync();
|
|
var result = rows.Select(product => ProductResponse.From(product, true)).ToList();
|
|
|
|
return Results.Ok(new { count = result.Count, products = result });
|
|
});
|
|
|
|
app.MapPost("/api/products", async (
|
|
ProductUpsertRequest request,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var barcode = NormalizeBarcode(request.Barcode);
|
|
if (string.IsNullOrWhiteSpace(barcode) || string.IsNullOrWhiteSpace(request.Name))
|
|
{
|
|
return Results.BadRequest(new { message = "Barcode and name are required." });
|
|
}
|
|
|
|
var product = await db.Products.FindAsync(barcode);
|
|
if (product is null)
|
|
{
|
|
product = new Product
|
|
{
|
|
Barcode = barcode,
|
|
Name = request.Name.Trim(),
|
|
Source = "manual"
|
|
};
|
|
db.Products.Add(product);
|
|
}
|
|
|
|
product.Name = request.Name.Trim();
|
|
product.Brand = Clean(request.Brand);
|
|
product.Quantity = Clean(request.Quantity);
|
|
product.ImageUrl = Clean(request.ImageUrl);
|
|
product.Categories = Clean(request.Categories);
|
|
product.Labels = Clean(request.Labels);
|
|
product.NutriScore = Clean(request.NutriScore);
|
|
product.Ecoscore = Clean(request.Ecoscore);
|
|
product.IngredientsText = Clean(request.IngredientsText);
|
|
product.Source = "manual";
|
|
product.LastLookupUtc = DateTimeOffset.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Ok(ProductResponse.From(product, true));
|
|
});
|
|
|
|
app.MapDelete("/api/products/{barcode}", async (
|
|
string barcode,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var normalized = NormalizeBarcode(barcode);
|
|
var product = await db.Products.FindAsync(normalized);
|
|
if (product is null)
|
|
{
|
|
return Results.NotFound(new { found = false, barcode = normalized });
|
|
}
|
|
|
|
db.Products.Remove(product);
|
|
await db.SaveChangesAsync();
|
|
return Results.Ok(new { deleted = true, barcode = normalized });
|
|
});
|
|
|
|
app.MapPost("/api/food-events", async (
|
|
FoodLearningEventRequest request,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var productName = Clean(request.ProductName);
|
|
var barcode = Clean(request.Barcode) is { } rawBarcode
|
|
? NormalizeBarcode(rawBarcode)
|
|
: null;
|
|
if (string.IsNullOrWhiteSpace(productName) && string.IsNullOrWhiteSpace(barcode))
|
|
{
|
|
return Results.BadRequest(new { message = "Product name or barcode is required." });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(productName) && !string.IsNullOrWhiteSpace(barcode))
|
|
{
|
|
productName = (await db.Products.FindAsync(barcode))?.Name;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(productName))
|
|
{
|
|
return Results.BadRequest(new { message = "Product name could not be resolved." });
|
|
}
|
|
|
|
var occurredUtc = request.OccurredUtc ?? DateTimeOffset.UtcNow;
|
|
var localTime = request.LocalTime ?? occurredUtc.ToLocalTime();
|
|
var learningEvent = new FoodLearningEvent
|
|
{
|
|
ProductName = productName.Trim(),
|
|
Barcode = string.IsNullOrWhiteSpace(barcode) ? null : barcode,
|
|
Action = Clean(request.Action) ?? "stock-added",
|
|
Amount = request.Amount,
|
|
Unit = Clean(request.Unit),
|
|
Location = Clean(request.Location),
|
|
OccurredUtc = occurredUtc,
|
|
LocalHour = localTime.Hour,
|
|
DayOfMonth = localTime.Day,
|
|
Month = localTime.Month,
|
|
DayOfWeek = (int)localTime.DayOfWeek,
|
|
CreatedUtc = DateTimeOffset.UtcNow
|
|
};
|
|
|
|
db.FoodLearningEvents.Add(learningEvent);
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Ok(FoodLearningEventResponse.From(learningEvent));
|
|
});
|
|
|
|
app.MapGet("/api/food-events", async (
|
|
int? take,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var limit = Math.Clamp(take ?? 50, 1, 200);
|
|
var events = await db.FoodLearningEvents
|
|
.AsNoTracking()
|
|
.OrderByDescending(item => item.OccurredUtc)
|
|
.Take(limit)
|
|
.ToListAsync();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
count = events.Count,
|
|
events = events.Select(FoodLearningEventResponse.From)
|
|
});
|
|
});
|
|
|
|
app.MapGet("/api/shopping/recommendations", async (
|
|
int? take,
|
|
HttpContext context,
|
|
OtthonDbContext db) =>
|
|
{
|
|
if (!HasApiToken(context))
|
|
{
|
|
return Results.Unauthorized();
|
|
}
|
|
|
|
var now = DateTimeOffset.Now;
|
|
var limit = Math.Clamp(take ?? 12, 1, 50);
|
|
var events = await db.FoodLearningEvents
|
|
.AsNoTracking()
|
|
.Where(item => item.OccurredUtc >= DateTimeOffset.UtcNow.AddDays(-180))
|
|
.OrderByDescending(item => item.OccurredUtc)
|
|
.Take(1000)
|
|
.ToListAsync();
|
|
|
|
var recommendations = events
|
|
.GroupBy(item => NormalizeFoodKey(item.Barcode, item.ProductName))
|
|
.Select(group => BuildRecommendation(group, now))
|
|
.OrderByDescending(item => item.Score)
|
|
.ThenBy(item => item.ProductName)
|
|
.Take(limit)
|
|
.ToList();
|
|
|
|
return Results.Ok(new
|
|
{
|
|
generatedAt = now,
|
|
count = recommendations.Count,
|
|
recommendations
|
|
});
|
|
});
|
|
|
|
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)
|
|
{
|
|
var expected = context.RequestServices
|
|
.GetRequiredService<IConfiguration>()["OTTHON_API_TOKEN"] ?? "change-me-local-token";
|
|
|
|
if (context.Request.Headers.TryGetValue("X-Otthon-Token", out var headerToken)
|
|
&& headerToken == expected)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (context.Request.Query.TryGetValue("token", out var queryToken)
|
|
&& queryToken == expected)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static string NormalizeBarcode(string barcode) =>
|
|
new(barcode.Where(char.IsDigit).ToArray());
|
|
|
|
static string? Clean(string? value)
|
|
{
|
|
var trimmed = value?.Trim();
|
|
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 async Task<string?> GetSetting(OtthonDbContext db, string key)
|
|
{
|
|
var setting = await db.AppSettings.FindAsync(key);
|
|
return Clean(setting?.Value);
|
|
}
|
|
|
|
static async Task SetSetting(OtthonDbContext db, string key, string value)
|
|
{
|
|
var setting = await db.AppSettings.FindAsync(key);
|
|
if (setting is null)
|
|
{
|
|
setting = new AppSetting
|
|
{
|
|
Key = key,
|
|
Value = value,
|
|
UpdatedUtc = DateTimeOffset.UtcNow
|
|
};
|
|
db.AppSettings.Add(setting);
|
|
}
|
|
else
|
|
{
|
|
setting.Value = value;
|
|
setting.UpdatedUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
static void ApplyProductLookup(Product target, Product lookup, string source)
|
|
{
|
|
target.Name = lookup.Name;
|
|
target.Brand = lookup.Brand;
|
|
target.Quantity = lookup.Quantity;
|
|
target.ImageUrl = lookup.ImageUrl;
|
|
target.Categories = lookup.Categories;
|
|
target.Labels = lookup.Labels;
|
|
target.NutriScore = lookup.NutriScore;
|
|
target.Ecoscore = lookup.Ecoscore;
|
|
target.IngredientsText = lookup.IngredientsText;
|
|
target.Source = source;
|
|
target.LastLookupUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
static ShoppingRecommendationResponse BuildRecommendation(
|
|
IGrouping<string, FoodLearningEvent> group,
|
|
DateTimeOffset now)
|
|
{
|
|
var rows = group.OrderByDescending(item => item.OccurredUtc).ToList();
|
|
var latest = rows[0];
|
|
var frequencyScore = Math.Min(rows.Count * 8, 60);
|
|
var daysSinceLatest = Math.Max(0, (DateTimeOffset.UtcNow - latest.OccurredUtc).TotalDays);
|
|
var recencyScore = Math.Max(0, 35 - daysSinceLatest);
|
|
var hourScore = rows.Count(item => Math.Abs(item.LocalHour - now.Hour) <= 2) * 3;
|
|
var dayOfMonthScore = rows.Count(item => Math.Abs(item.DayOfMonth - now.Day) <= 2) * 4;
|
|
var monthScore = rows.Count(item => item.Month == now.Month) * 2;
|
|
var score = Math.Round(frequencyScore + recencyScore + hourScore + dayOfMonthScore + monthScore, 2);
|
|
var reasons = new List<string>
|
|
{
|
|
$"{rows.Count} kor\u00e1bbi el\u0151fordul\u00e1s",
|
|
$"legut\u00f3bb: {latest.OccurredUtc.LocalDateTime:yyyy.MM.dd HH:mm}"
|
|
};
|
|
if (hourScore > 0)
|
|
{
|
|
reasons.Add("hasonl\u00f3 napszakban gyakori");
|
|
}
|
|
|
|
if (dayOfMonthScore > 0)
|
|
{
|
|
reasons.Add("h\u00f3napon bel\u00fcl mostan\u00e1ban szokott el\u0151j\u00f6nni");
|
|
}
|
|
|
|
if (monthScore > 0)
|
|
{
|
|
reasons.Add("ebben a h\u00f3napban m\u00e1r volt r\u00e1 minta");
|
|
}
|
|
|
|
return new ShoppingRecommendationResponse(
|
|
latest.ProductName,
|
|
latest.Barcode,
|
|
latest.Unit,
|
|
latest.Location,
|
|
score,
|
|
rows.Count,
|
|
latest.OccurredUtc,
|
|
reasons);
|
|
}
|
|
|
|
static string NormalizeFoodKey(string? barcode, string productName)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(barcode))
|
|
{
|
|
return $"barcode:{barcode}";
|
|
}
|
|
|
|
var letters = productName
|
|
.Trim()
|
|
.ToLowerInvariant()
|
|
.Where(character => char.IsLetterOrDigit(character) || char.IsWhiteSpace(character))
|
|
.ToArray();
|
|
return $"name:{new string(letters)}";
|
|
}
|
|
|
|
static async Task EnsureDatabaseReady(OtthonDbContext db, ILogger logger)
|
|
{
|
|
const int maxAttempts = 30;
|
|
for (var attempt = 1; attempt <= maxAttempts; attempt++)
|
|
{
|
|
try
|
|
{
|
|
await db.Database.EnsureCreatedAsync();
|
|
await EnsureProductSchemaAsync(db);
|
|
await EnsureFoodLearningSchemaAsync(db);
|
|
await EnsureWorkspaceSchemaAsync(db);
|
|
await EnsureAppSettingsSchemaAsync(db);
|
|
return;
|
|
}
|
|
catch (Exception ex) when (attempt < maxAttempts)
|
|
{
|
|
logger.LogWarning(
|
|
ex,
|
|
"JARVIS API is waiting for the database. Attempt {Attempt}/{MaxAttempts}.",
|
|
attempt,
|
|
maxAttempts);
|
|
await Task.Delay(TimeSpan.FromSeconds(3));
|
|
}
|
|
}
|
|
|
|
await db.Database.EnsureCreatedAsync();
|
|
await EnsureProductSchemaAsync(db);
|
|
await EnsureFoodLearningSchemaAsync(db);
|
|
await EnsureWorkspaceSchemaAsync(db);
|
|
await EnsureAppSettingsSchemaAsync(db);
|
|
}
|
|
|
|
static async Task EnsureProductSchemaAsync(OtthonDbContext db)
|
|
{
|
|
var sql = """
|
|
IF COL_LENGTH('Products', 'Categories') IS NULL
|
|
ALTER TABLE Products ADD Categories nvarchar(1000) NULL;
|
|
IF COL_LENGTH('Products', 'Labels') IS NULL
|
|
ALTER TABLE Products ADD Labels nvarchar(1000) NULL;
|
|
IF COL_LENGTH('Products', 'NutriScore') IS NULL
|
|
ALTER TABLE Products ADD NutriScore nvarchar(32) NULL;
|
|
IF COL_LENGTH('Products', 'Ecoscore') IS NULL
|
|
ALTER TABLE Products ADD Ecoscore nvarchar(32) NULL;
|
|
IF COL_LENGTH('Products', 'IngredientsText') IS NULL
|
|
ALTER TABLE Products ADD IngredientsText nvarchar(max) 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 EnsureAppSettingsSchemaAsync(OtthonDbContext db)
|
|
{
|
|
var sql = """
|
|
IF OBJECT_ID('AppSettings', 'U') IS NULL
|
|
BEGIN
|
|
CREATE TABLE AppSettings (
|
|
[Key] nvarchar(160) NOT NULL CONSTRAINT PK_AppSettings PRIMARY KEY,
|
|
[Value] nvarchar(max) NOT NULL,
|
|
UpdatedUtc datetimeoffset NOT NULL
|
|
);
|
|
END
|
|
""";
|
|
await db.Database.ExecuteSqlRawAsync(sql);
|
|
}
|
|
|
|
static async Task EnsureFoodLearningSchemaAsync(OtthonDbContext db)
|
|
{
|
|
var sql = """
|
|
IF OBJECT_ID('FoodLearningEvents', 'U') IS NULL
|
|
BEGIN
|
|
CREATE TABLE FoodLearningEvents (
|
|
Id int IDENTITY(1,1) NOT NULL CONSTRAINT PK_FoodLearningEvents PRIMARY KEY,
|
|
ProductName nvarchar(300) NOT NULL,
|
|
Barcode nvarchar(64) NULL,
|
|
Action nvarchar(80) NOT NULL,
|
|
Amount int NULL,
|
|
Unit nvarchar(80) NULL,
|
|
Location nvarchar(120) NULL,
|
|
OccurredUtc datetimeoffset NOT NULL,
|
|
LocalHour int NOT NULL,
|
|
DayOfMonth int NOT NULL,
|
|
Month int NOT NULL,
|
|
DayOfWeek int NOT NULL,
|
|
CreatedUtc datetimeoffset NOT NULL
|
|
);
|
|
END
|
|
""";
|
|
await db.Database.ExecuteSqlRawAsync(sql);
|
|
}
|
|
|
|
static async Task<Product?> TryFetchProductFromOpenFoodFacts(
|
|
string barcode,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
var client = httpClientFactory.CreateClient("open-food-facts");
|
|
using var response = await client.GetAsync(
|
|
$"api/v2/product/{barcode}.json?fields=code,product_name,product_name_hu,product_name_en,generic_name,brands,quantity,image_front_url,image_url,categories,categories_tags,labels,labels_tags,nutriscore_grade,ecoscore_grade,ingredients_text,ingredients_text_hu,ingredients_text_en");
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
|
using var document = await JsonDocument.ParseAsync(stream);
|
|
if (!document.RootElement.TryGetProperty("status", out var status)
|
|
|| status.GetInt32() != 1
|
|
|| !document.RootElement.TryGetProperty("product", out var productJson))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var name = GetString(productJson, "product_name_hu")
|
|
?? GetString(productJson, "product_name")
|
|
?? GetString(productJson, "product_name_en")
|
|
?? GetString(productJson, "generic_name");
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new Product
|
|
{
|
|
Barcode = barcode,
|
|
Name = name,
|
|
Brand = GetString(productJson, "brands"),
|
|
Quantity = GetString(productJson, "quantity"),
|
|
ImageUrl = GetString(productJson, "image_front_url")
|
|
?? GetString(productJson, "image_url"),
|
|
Categories = GetString(productJson, "categories")
|
|
?? GetTagList(productJson, "categories_tags"),
|
|
Labels = GetString(productJson, "labels")
|
|
?? GetTagList(productJson, "labels_tags"),
|
|
NutriScore = GetString(productJson, "nutriscore_grade")?.ToUpperInvariant(),
|
|
Ecoscore = GetString(productJson, "ecoscore_grade")?.ToUpperInvariant(),
|
|
IngredientsText = GetString(productJson, "ingredients_text_hu")
|
|
?? GetString(productJson, "ingredients_text")
|
|
?? GetString(productJson, "ingredients_text_en"),
|
|
Source = "openfoodfacts",
|
|
LastLookupUtc = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
static string? GetTagList(JsonElement element, string propertyName)
|
|
{
|
|
if (!element.TryGetProperty(propertyName, out var property)
|
|
|| property.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var values = property
|
|
.EnumerateArray()
|
|
.Where(item => item.ValueKind == JsonValueKind.String)
|
|
.Select(item => item.GetString())
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value!.Contains(':') ? value[(value.IndexOf(':') + 1)..] : value)
|
|
.ToArray();
|
|
|
|
return values.Length == 0 ? null : string.Join(", ", values);
|
|
}
|
|
|
|
static string? GetString(JsonElement element, string propertyName)
|
|
{
|
|
if (!element.TryGetProperty(propertyName, out var property)
|
|
|| property.ValueKind != JsonValueKind.String)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var value = property.GetString();
|
|
return string.IsNullOrWhiteSpace(value) ? null : value;
|
|
}
|
|
|
|
static string LoginPage(string? error = null) => $$"""
|
|
<!doctype html>
|
|
<html lang="hu">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>JARVIS API login</title>
|
|
<style>{{AdminCss()}}</style>
|
|
</head>
|
|
<body>
|
|
<main class="card narrow">
|
|
<h1>JARVIS API</h1>
|
|
<p>Admin login for device management.</p>
|
|
{{(error is null ? "" : $"<div class=\"error\">{error}</div>")}}
|
|
<form method="post" action="/login">
|
|
<label>Password</label>
|
|
<input name="password" type="password" autofocus required>
|
|
<button>Sign in</button>
|
|
</form>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
""";
|
|
|
|
static string DevicesPage(IReadOnlyList<RegisteredDevice> devices)
|
|
{
|
|
var rows = string.Join("", devices.Select(device => $$"""
|
|
<tr>
|
|
<td>{{Html(device.Name)}}</td>
|
|
<td>{{Html(device.Platform)}}</td>
|
|
<td><code>{{Html(device.DeviceToken)}}</code></td>
|
|
<td>{{device.CreatedUtc.LocalDateTime:yyyy.MM.dd HH:mm}}</td>
|
|
<td>{{(device.LastSeenUtc?.LocalDateTime.ToString("yyyy.MM.dd HH:mm") ?? "-")}}</td>
|
|
<td><span class="{{(device.IsActive ? "status approved" : "status pending")}}">{{(device.IsActive ? "approved" : "pending approval")}}</span></td>
|
|
<td>
|
|
<form method="post" action="/devices/{{device.Id}}/approve">
|
|
<button class="approve" {{(device.IsActive ? "disabled" : "")}}>Approve</button>
|
|
</form>
|
|
<form method="post" action="/devices/{{device.Id}}/deactivate">
|
|
<button class="secondary" {{(device.IsActive ? "" : "disabled")}}>Disable</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
"""));
|
|
|
|
return $$"""
|
|
<!doctype html>
|
|
<html lang="hu">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Devices</title>
|
|
<style>{{AdminCss()}}</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<div class="top">
|
|
<div>
|
|
<p class="eyebrow">JARVIS API</p>
|
|
<h1>Registered devices</h1>
|
|
</div>
|
|
<div class="actions">
|
|
<a class="button secondary" href="/settings">Settings</a>
|
|
<form method="post" action="/logout"><button class="secondary">Sign out</button></form>
|
|
</div>
|
|
</div>
|
|
<p class="hint">The app can submit a device request through <code>POST /api/devices/register</code>. New devices stay pending until approved here.</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Platform</th>
|
|
<th>Token</th>
|
|
<th>Created</th>
|
|
<th>Last seen</th>
|
|
<th>Status</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>{{(rows.Length == 0 ? "<tr><td colspan=\"7\">No registered devices yet.</td></tr>" : rows)}}</tbody>
|
|
</table>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|
|
|
|
static string SettingsPage(string googleClientId) => $$"""
|
|
<!doctype html>
|
|
<html lang="hu">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>JARVIS API settings</title>
|
|
<style>{{AdminCss()}}</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<div class="top">
|
|
<div>
|
|
<p class="eyebrow">JARVIS API</p>
|
|
<h1>Settings</h1>
|
|
</div>
|
|
<div class="actions">
|
|
<a class="button secondary" href="/devices">Devices</a>
|
|
<form method="post" action="/logout"><button class="secondary">Sign out</button></form>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="panel">
|
|
<h2>Google Calendar OAuth</h2>
|
|
<p class="hint">Set the <strong>Web application OAuth Client ID</strong> from Google Cloud Console. Tablets will fetch this automatically; family members only see the Google consent screen.</p>
|
|
<form method="post" action="/settings/google-calendar">
|
|
<label>Server client ID</label>
|
|
<input name="serverClientId" value="{{Html(googleClientId)}}" placeholder="1234567890-xxxx.apps.googleusercontent.com">
|
|
<button>Save Google Calendar settings</button>
|
|
</form>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
""";
|
|
|
|
static string Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
|
|
|
|
static string GoogleCalendarServerClientIdSettingKey() => "GoogleCalendarServerClientId";
|
|
|
|
static string AdminCss() => """
|
|
:root { color-scheme: light; font-family: Inter, Segoe UI, Arial, sans-serif; }
|
|
body { margin: 0; min-height: 100vh; background: #f4f1ea; color: #17342d; display: grid; place-items: center; }
|
|
.card { width: min(1120px, calc(100vw - 40px)); background: #fff; border-radius: 28px; padding: 28px; box-shadow: 0 24px 60px rgba(47, 107, 91, .16); }
|
|
.narrow { width: min(420px, calc(100vw - 40px)); }
|
|
.top { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
.eyebrow { margin: 0 0 4px; color: #2f6b5b; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
|
h1 { margin: 0 0 18px; font-size: clamp(28px, 4vw, 44px); }
|
|
p { color: #5b6d67; }
|
|
label { display: block; margin-bottom: 8px; font-weight: 800; }
|
|
input { width: 100%; box-sizing: border-box; border: 1px solid #d9e2de; border-radius: 16px; padding: 14px; font-size: 16px; }
|
|
button, .button { border: 0; border-radius: 999px; padding: 12px 18px; background: #0f7b61; color: white; font-weight: 900; cursor: pointer; margin-top: 14px; text-decoration: none; display: inline-block; }
|
|
button.secondary { background: #e6f1ed; color: #17342d; margin-top: 0; }
|
|
.button.secondary { background: #e6f1ed; color: #17342d; margin-top: 0; }
|
|
button.approve { background: #0f7b61; color: white; margin-top: 0; margin-right: 8px; }
|
|
button:disabled { opacity: .35; cursor: not-allowed; }
|
|
td form { display: inline-block; margin: 0; }
|
|
.actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
.actions form { margin: 0; }
|
|
.panel { margin-top: 20px; padding: 20px; border: 1px solid #edf2ef; border-radius: 22px; }
|
|
.panel h2 { margin-top: 0; }
|
|
.error { background: #ffe8e0; color: #9b341f; padding: 12px 14px; border-radius: 14px; margin-bottom: 14px; }
|
|
.hint { background: #e6f1ed; padding: 14px 16px; border-radius: 18px; }
|
|
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 18px; }
|
|
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #edf2ef; vertical-align: top; }
|
|
th { font-size: 12px; text-transform: uppercase; color: #688078; letter-spacing: .08em; }
|
|
code { background: #f3f7f5; padding: 3px 6px; border-radius: 8px; }
|
|
.status { display: inline-block; border-radius: 999px; padding: 5px 10px; font-size: 12px; font-weight: 900; }
|
|
.status.approved { background: #e3f4ed; color: #0f7b61; }
|
|
.status.pending { background: #fff0d4; color: #9b6412; }
|
|
""";
|
|
|
|
public sealed class OtthonDbContext(DbContextOptions<OtthonDbContext> options)
|
|
: DbContext(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>();
|
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.Entity<AppSetting>().HasKey(setting => setting.Key);
|
|
modelBuilder.Entity<AppSetting>().Property(setting => setting.Key).HasMaxLength(160);
|
|
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);
|
|
modelBuilder.Entity<Product>().Property(product => product.Brand).HasMaxLength(300);
|
|
modelBuilder.Entity<Product>().Property(product => product.Quantity).HasMaxLength(120);
|
|
modelBuilder.Entity<Product>().Property(product => product.ImageUrl).HasMaxLength(1000);
|
|
modelBuilder.Entity<Product>().Property(product => product.Categories).HasMaxLength(1000);
|
|
modelBuilder.Entity<Product>().Property(product => product.Labels).HasMaxLength(1000);
|
|
modelBuilder.Entity<Product>().Property(product => product.NutriScore).HasMaxLength(32);
|
|
modelBuilder.Entity<Product>().Property(product => product.Ecoscore).HasMaxLength(32);
|
|
modelBuilder.Entity<RegisteredDevice>().Property(device => device.Name).HasMaxLength(120);
|
|
modelBuilder.Entity<RegisteredDevice>().Property(device => device.DeviceToken).HasMaxLength(64);
|
|
modelBuilder.Entity<FoodLearningEvent>().Property(item => item.ProductName).HasMaxLength(300);
|
|
modelBuilder.Entity<FoodLearningEvent>().Property(item => item.Barcode).HasMaxLength(64);
|
|
modelBuilder.Entity<FoodLearningEvent>().Property(item => item.Action).HasMaxLength(80);
|
|
modelBuilder.Entity<FoodLearningEvent>().Property(item => item.Unit).HasMaxLength(80);
|
|
modelBuilder.Entity<FoodLearningEvent>().Property(item => item.Location).HasMaxLength(120);
|
|
}
|
|
}
|
|
|
|
public sealed class RegisteredDevice
|
|
{
|
|
public int Id { get; set; }
|
|
public required string Name { get; set; }
|
|
public required string Platform { get; set; }
|
|
public required string DeviceToken { get; set; }
|
|
public DateTimeOffset CreatedUtc { get; set; }
|
|
public DateTimeOffset? LastSeenUtc { get; set; }
|
|
public bool IsActive { get; set; } = true;
|
|
}
|
|
|
|
public sealed class Product
|
|
{
|
|
public required string Barcode { get; set; }
|
|
public required string Name { get; set; }
|
|
public string? Brand { get; set; }
|
|
public string? Quantity { get; set; }
|
|
public string? ImageUrl { get; set; }
|
|
public string? Categories { get; set; }
|
|
public string? Labels { get; set; }
|
|
public string? NutriScore { get; set; }
|
|
public string? Ecoscore { get; set; }
|
|
public string? IngredientsText { get; set; }
|
|
public string Source { get; set; } = "manual";
|
|
public DateTimeOffset LastLookupUtc { get; set; }
|
|
}
|
|
|
|
public sealed class FoodLearningEvent
|
|
{
|
|
public int Id { get; set; }
|
|
public required string ProductName { get; set; }
|
|
public string? Barcode { get; set; }
|
|
public required string Action { get; set; }
|
|
public int? Amount { get; set; }
|
|
public string? Unit { get; set; }
|
|
public string? Location { get; set; }
|
|
public DateTimeOffset OccurredUtc { get; set; }
|
|
public int LocalHour { get; set; }
|
|
public int DayOfMonth { get; set; }
|
|
public int Month { get; set; }
|
|
public int DayOfWeek { 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 class AppSetting
|
|
{
|
|
public required string Key { get; set; }
|
|
public required string Value { get; set; }
|
|
public DateTimeOffset UpdatedUtc { get; set; }
|
|
}
|
|
|
|
public sealed record LoginForm(string Password);
|
|
public sealed record GoogleCalendarSettingsForm(string? ServerClientId);
|
|
public sealed record DeviceRegistrationRequest(string? DeviceName, string? Platform);
|
|
public sealed record ProductUpsertRequest(
|
|
string Barcode,
|
|
string Name,
|
|
string? Brand,
|
|
string? Quantity,
|
|
string? ImageUrl,
|
|
string? Categories,
|
|
string? Labels,
|
|
string? NutriScore,
|
|
string? Ecoscore,
|
|
string? IngredientsText);
|
|
|
|
public sealed record FoodLearningEventRequest(
|
|
string? ProductName,
|
|
string? Barcode,
|
|
string? Action,
|
|
int? Amount,
|
|
string? Unit,
|
|
string? Location,
|
|
DateTimeOffset? OccurredUtc,
|
|
DateTimeOffset? LocalTime);
|
|
|
|
public sealed record FoodLearningEventResponse(
|
|
int Id,
|
|
string ProductName,
|
|
string? Barcode,
|
|
string Action,
|
|
int? Amount,
|
|
string? Unit,
|
|
string? Location,
|
|
DateTimeOffset OccurredUtc,
|
|
int LocalHour,
|
|
int DayOfMonth,
|
|
int Month,
|
|
int DayOfWeek)
|
|
{
|
|
public static FoodLearningEventResponse From(FoodLearningEvent item) => new(
|
|
item.Id,
|
|
item.ProductName,
|
|
item.Barcode,
|
|
item.Action,
|
|
item.Amount,
|
|
item.Unit,
|
|
item.Location,
|
|
item.OccurredUtc,
|
|
item.LocalHour,
|
|
item.DayOfMonth,
|
|
item.Month,
|
|
item.DayOfWeek);
|
|
}
|
|
|
|
public sealed record ShoppingRecommendationResponse(
|
|
string ProductName,
|
|
string? Barcode,
|
|
string? Unit,
|
|
string? Location,
|
|
double Score,
|
|
int Occurrences,
|
|
DateTimeOffset LastSeenUtc,
|
|
IReadOnlyList<string> Reasons);
|
|
|
|
public sealed record WorkspaceStateRequest(string StateJson);
|
|
|
|
public sealed record WorkspaceStateResponse(
|
|
string WorkspaceToken,
|
|
string StateJson,
|
|
DateTimeOffset UpdatedUtc);
|
|
|
|
public sealed record AppConfigResponse(
|
|
string? GoogleCalendarServerClientId,
|
|
bool GoogleCalendarConfigured);
|
|
|
|
public sealed record ProductResponse(
|
|
bool Found,
|
|
string Barcode,
|
|
string Name,
|
|
string? Brand,
|
|
string? Quantity,
|
|
string? ImageUrl,
|
|
string? Categories,
|
|
string? Labels,
|
|
string? NutriScore,
|
|
string? Ecoscore,
|
|
string? IngredientsText,
|
|
string Source,
|
|
bool FromCache,
|
|
DateTimeOffset LastLookupUtc,
|
|
string? Message = null)
|
|
{
|
|
public static ProductResponse From(Product product, bool fromCache, string? message = null) => new(
|
|
true,
|
|
product.Barcode,
|
|
product.Name,
|
|
product.Brand,
|
|
product.Quantity,
|
|
product.ImageUrl,
|
|
product.Categories,
|
|
product.Labels,
|
|
product.NutriScore,
|
|
product.Ecoscore,
|
|
product.IngredientsText,
|
|
product.Source,
|
|
fromCache,
|
|
product.LastLookupUtc,
|
|
message);
|
|
}
|