naptar-es-feladatkezelo/api/OtthonApi/Program.cs

1010 lines
32 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.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.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 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);
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);
}
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 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>
<form method="post" action="/logout"><button class="secondary">Sign out</button></form>
</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 Html(string? value) => System.Net.WebUtility.HtmlEncode(value ?? "");
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 { border: 0; border-radius: 999px; padding: 12px 18px; background: #0f7b61; color: white; font-weight: 900; cursor: pointer; margin-top: 14px; }
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; }
.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>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
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 record LoginForm(string Password);
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 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);
}