commit eee1d5945c63ce4ff10e515e893d49a060c98d50 Author: zsolt Date: Thu Aug 13 00:14:13 2026 +0200 Initial JARVIS API codebase diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f34637 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +*.tgz +.vs/ diff --git a/OtthonApi/.dockerignore b/OtthonApi/.dockerignore new file mode 100644 index 0000000..54c45ec --- /dev/null +++ b/OtthonApi/.dockerignore @@ -0,0 +1,6 @@ +bin/ +obj/ +.vs/ +.vscode/ +*.user +*.suo diff --git a/OtthonApi/Dockerfile b/OtthonApi/Dockerfile new file mode 100644 index 0000000..e206401 --- /dev/null +++ b/OtthonApi/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src +COPY OtthonApi.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "OtthonApi.dll"] diff --git a/OtthonApi/OtthonApi.csproj b/OtthonApi/OtthonApi.csproj new file mode 100644 index 0000000..458b82f --- /dev/null +++ b/OtthonApi/OtthonApi.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/OtthonApi/OtthonApi.http b/OtthonApi/OtthonApi.http new file mode 100644 index 0000000..6a4d98b --- /dev/null +++ b/OtthonApi/OtthonApi.http @@ -0,0 +1,6 @@ +@OtthonApi_HostAddress = http://localhost:5278 + +GET {{OtthonApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/OtthonApi/Program.cs b/OtthonApi/Program.cs new file mode 100644 index 0000000..65f3707 --- /dev/null +++ b/OtthonApi/Program.cs @@ -0,0 +1,1009 @@ +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(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(); + var logger = scope.ServiceProvider + .GetRequiredService() + .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()["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 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 + { + $"{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 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) => $$""" + + + + + + JARVIS API login + + + +
+

JARVIS API

+

Admin login for device management.

+ {{(error is null ? "" : $"
{error}
")}} +
+ + + +
+
+ + +"""; + +static string DevicesPage(IReadOnlyList devices) +{ + var rows = string.Join("", devices.Select(device => $$""" + + {{Html(device.Name)}} + {{Html(device.Platform)}} + {{Html(device.DeviceToken)}} + {{device.CreatedUtc.LocalDateTime:yyyy.MM.dd HH:mm}} + {{(device.LastSeenUtc?.LocalDateTime.ToString("yyyy.MM.dd HH:mm") ?? "-")}} + {{(device.IsActive ? "approved" : "pending approval")}} + +
+ +
+
+ +
+ + +""")); + + return $$""" + + + + + + Devices + + + +
+
+
+

JARVIS API

+

Registered devices

+
+
+
+

The app can submit a device request through POST /api/devices/register. New devices stay pending until approved here.

+ + + + + + + + + + + + + {{(rows.Length == 0 ? "" : rows)}} +
NamePlatformTokenCreatedLast seenStatus
No registered devices yet.
+
+ + +"""; +} + +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 options) + : DbContext(options) +{ + public DbSet Devices => Set(); + public DbSet Products => Set(); + public DbSet FoodLearningEvents => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(product => product.Barcode); + modelBuilder.Entity().Property(product => product.Barcode).HasMaxLength(64); + modelBuilder.Entity().Property(product => product.Name).HasMaxLength(300); + modelBuilder.Entity().Property(product => product.Brand).HasMaxLength(300); + modelBuilder.Entity().Property(product => product.Quantity).HasMaxLength(120); + modelBuilder.Entity().Property(product => product.ImageUrl).HasMaxLength(1000); + modelBuilder.Entity().Property(product => product.Categories).HasMaxLength(1000); + modelBuilder.Entity().Property(product => product.Labels).HasMaxLength(1000); + modelBuilder.Entity().Property(product => product.NutriScore).HasMaxLength(32); + modelBuilder.Entity().Property(product => product.Ecoscore).HasMaxLength(32); + modelBuilder.Entity().Property(device => device.Name).HasMaxLength(120); + modelBuilder.Entity().Property(device => device.DeviceToken).HasMaxLength(64); + modelBuilder.Entity().Property(item => item.ProductName).HasMaxLength(300); + modelBuilder.Entity().Property(item => item.Barcode).HasMaxLength(64); + modelBuilder.Entity().Property(item => item.Action).HasMaxLength(80); + modelBuilder.Entity().Property(item => item.Unit).HasMaxLength(80); + modelBuilder.Entity().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 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); +} diff --git a/OtthonApi/Properties/launchSettings.json b/OtthonApi/Properties/launchSettings.json new file mode 100644 index 0000000..9341a63 --- /dev/null +++ b/OtthonApi/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5278", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/OtthonApi/appsettings.Development.json b/OtthonApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/OtthonApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/OtthonApi/appsettings.json b/OtthonApi/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/OtthonApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/OtthonBackend.sln b/OtthonBackend.sln new file mode 100644 index 0000000..006250f --- /dev/null +++ b/OtthonBackend.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtthonApi", "OtthonApi\OtthonApi.csproj", "{CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x64.Build.0 = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Debug|x86.Build.0 = Debug|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|Any CPU.Build.0 = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x64.ActiveCfg = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x64.Build.0 = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x86.ActiveCfg = Release|Any CPU + {CE492B1E-8A9C-40F9-B329-0AF9EA8AB87C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..f112930 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# JARVIS API + +Inditas Docker Desktopon: + +```powershell +docker compose -f api\docker-compose.yml up -d --build +``` + +Helyi cimek: + +- API: `http://localhost:5088` +- Admin felulet: `http://localhost:5088/devices` +- Admin jelszo: `change-me-admin` +- API token: `change-me-local-token` + +Elesebb hasznalathoz a `docker-compose.yml` fajlban csereld le az +`OTTHON_API_TOKEN`, `OTTHON_ADMIN_PASSWORD` es `MSSQL_SA_PASSWORD` ertekeket. + +Pelda termekkeresesre: + +```powershell +curl -H "X-Otthon-Token: change-me-local-token" http://localhost:5088/api/products/5449000000996 +``` diff --git a/deploy-server.sh b/deploy-server.sh new file mode 100644 index 0000000..ba26729 --- /dev/null +++ b/deploy-server.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p /root/docker/jarvis-api +cd /root/docker/jarvis-api +tar xzf jarvis-api-deploy.tgz + +random_text() { + tr -dc 'A-Za-z0-9' .env + chmod 600 .env +fi + +docker compose -f docker-compose.server.yml --env-file .env up -d --build +docker compose -f docker-compose.server.yml --env-file .env ps diff --git a/docker-compose.server.yml b/docker-compose.server.yml new file mode 100644 index 0000000..2a288c4 --- /dev/null +++ b/docker-compose.server.yml @@ -0,0 +1,28 @@ +services: + jarvis-api: + build: + context: ./OtthonApi + container_name: jarvis-api + restart: unless-stopped + depends_on: + - mssql + environment: + ASPNETCORE_URLS: http://+:8080 + OTTHON_API_TOKEN: ${JARVIS_API_TOKEN} + OTTHON_ADMIN_PASSWORD: ${JARVIS_ADMIN_PASSWORD} + ConnectionStrings__Default: Server=mssql,1433;Database=JarvisHome;User Id=sa;Password=${JARVIS_SA_PASSWORD};TrustServerCertificate=True;Encrypt=False + ports: + - "127.0.0.1:5088:8080" + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: jarvis-mssql + restart: unless-stopped + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${JARVIS_SA_PASSWORD} + volumes: + - jarvis-mssql-data:/var/opt/mssql + +volumes: + jarvis-mssql-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9ee1f3c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + jarvis-api: + build: + context: ./OtthonApi + container_name: jarvis-api + restart: unless-stopped + depends_on: + - mssql + environment: + ASPNETCORE_URLS: http://+:8080 + OTTHON_API_TOKEN: change-me-local-token + OTTHON_ADMIN_PASSWORD: change-me-admin + ConnectionStrings__Default: Server=mssql,1433;Database=JarvisHome;User Id=sa;Password=Jarvis!2026Api;TrustServerCertificate=True;Encrypt=False + ports: + - "5088:8080" + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: jarvis-mssql + restart: unless-stopped + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: Jarvis!2026Api + ports: + - "14333:1433" + volumes: + - jarvis-mssql-data:/var/opt/mssql + +volumes: + jarvis-mssql-data: