From 16ce3aebd9e49e9b56a69c0024ef4c80b9ab5a77 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:03:41 +0100 Subject: [PATCH] add calorie-tracker as external flake service --- flake.lock | 98 ++++++++-- flake.nix | 6 +- .../calorie-tracker/default.nix | 97 ++++++++++ .../calorie-tracker/schema.sql | 180 ++++++++++++++++++ hosts/thegeneralist-central/configuration.nix | 1 + hosts/thegeneralist-central/site.nix | 3 +- 6 files changed, 370 insertions(+), 15 deletions(-) create mode 100644 hosts/thegeneralist-central/calorie-tracker/default.nix create mode 100644 hosts/thegeneralist-central/calorie-tracker/schema.sql diff --git a/flake.lock b/flake.lock index 3cc58f5..7de845f 100644 --- a/flake.lock +++ b/flake.lock @@ -44,6 +44,25 @@ "type": "github" } }, + "calorie-tracker": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1771307893, + "narHash": "sha256-wHBIQSFjo/KWcY9Xvq30wsVAaNGEgirwebFQjqujPiU=", + "ref": "refs/heads/master", + "rev": "977eeccd7f2702ae85ff562b4f8bd39c3cbb1898", + "revCount": 1, + "type": "git", + "url": "file:///home/thegeneralist/calorie-tracker" + }, + "original": { + "type": "git", + "url": "file:///home/thegeneralist/calorie-tracker" + } + }, "fenix": { "inputs": { "nixpkgs": [ @@ -117,10 +136,28 @@ "type": "github" } }, + "flake-utils_3": { + "inputs": { + "systems": "systems_4" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "ghostty": { "inputs": { "flake-compat": "flake-compat", - "flake-utils": "flake-utils", + "flake-utils": "flake-utils_2", "nixpkgs-stable": "nixpkgs-stable", "nixpkgs-unstable": "nixpkgs-unstable", "zig": "zig", @@ -233,11 +270,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1755186698, - "narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=", + "lastModified": 1771008912, + "narHash": "sha256-gf2AmWVTs8lEq7z/3ZAsgnZDhWIckkb+ZnAo5RzSxJg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c", + "rev": "a82ccc39b39b621151d6732718e3e250109076fa", "type": "github" }, "original": { @@ -280,6 +317,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1755186698, + "narHash": "sha256-wNO3+Ks2jZJ4nTHMuks+cxAiVBGNuEBXsT29Bz6HASo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fbcf476f790d8a217c3eab4e12033dc4a0f6d23c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1769789167, "narHash": "sha256-kKB3bqYJU5nzYeIROI82Ef9VtTbu4uA3YydSk/Bioa8=", @@ -297,23 +350,27 @@ }, "readlater-bot": { "inputs": { - "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs_2" + "flake-utils": "flake-utils_3", + "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1770458808, - "narHash": "sha256-Fs/DwFxitReM7PuN1aee8pcmRzST7wzX7WLeBK/lOAI=", - "path": "/home/thegeneralist/infofeeder-bot", - "type": "path" + "lastModified": 1771250778, + "narHash": "sha256-lmWwzbuMer8vjGXh37p79dSctaFNjTBW6Cp0T5R+ZiE=", + "ref": "refs/heads/master", + "rev": "181c03915b93d21c5d15a1375d3bc621b2992700", + "revCount": 17, + "type": "git", + "url": "file:///home/thegeneralist/infofeeder-bot" }, "original": { - "path": "/home/thegeneralist/infofeeder-bot", - "type": "path" + "type": "git", + "url": "file:///home/thegeneralist/infofeeder-bot" } }, "root": { "inputs": { "agenix": "agenix", + "calorie-tracker": "calorie-tracker", "fenix": "fenix", "ghostty": "ghostty", "home-manager": "home-manager", @@ -321,7 +378,7 @@ "homebrew-core": "homebrew-core", "nix-darwin": "nix-darwin", "nix-homebrew": "nix-homebrew", - "nixpkgs": "nixpkgs", + "nixpkgs": "nixpkgs_2", "readlater-bot": "readlater-bot" } }, @@ -387,6 +444,21 @@ "type": "github" } }, + "systems_4": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "zig": { "inputs": { "flake-compat": [ diff --git a/flake.nix b/flake.nix index e72df1d..40029a4 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,11 @@ }; readlater-bot = { - url = "path:/home/thegeneralist/infofeeder-bot"; + url = "git+file:///home/thegeneralist/infofeeder-bot"; + }; + + calorie-tracker = { + url = "git+file:///home/thegeneralist/calorie-tracker"; }; }; diff --git a/hosts/thegeneralist-central/calorie-tracker/default.nix b/hosts/thegeneralist-central/calorie-tracker/default.nix new file mode 100644 index 0000000..3868f03 --- /dev/null +++ b/hosts/thegeneralist-central/calorie-tracker/default.nix @@ -0,0 +1,97 @@ +{ pkgs, inputs, ... }: +let + sourceDir = "${inputs.calorie-tracker}"; + appDir = "/var/lib/calorie-tracker/app"; + dataDir = "/var/lib/calorie-tracker"; + port = 4322; + + acmeDomain = "thegeneralist01.com"; + domain = "calorie.${acmeDomain}"; + + ssl = { + forceSSL = true; + quic = true; + useACMEHost = acmeDomain; + }; +in +{ + systemd.services.calorie-tracker = { + description = "Calorie Tracker"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + + environment = { + NODE_ENV = "production"; + HOST = "127.0.0.1"; + PORT = toString port; + DATABASE_URL = "file:${dataDir}/dev.db"; + PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING = "1"; + + PRISMA_FMT_BINARY = "${pkgs.prisma-engines}/bin/prisma-fmt"; + PRISMA_SCHEMA_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/schema-engine"; + PRISMA_QUERY_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/query-engine"; + PRISMA_QUERY_ENGINE_LIBRARY = "${pkgs.prisma-engines}/lib/libquery_engine.node"; + }; + + path = with pkgs; [ + bash + coreutils + gnused + nodejs_22 + prisma + prisma-engines + rsync + sqlite + ]; + + preStart = '' + mkdir -p ${appDir} + rsync -a --delete --exclude ".git" --exclude "node_modules" --exclude "dist" --exclude ".astro" ${sourceDir}/ ${appDir}/ + + cd ${appDir} + + if [ ! -f .env ]; then + cp .env.example .env + fi + + sed -i 's#^DATABASE_URL=.*#DATABASE_URL="file:${dataDir}/dev.db"#' .env + + if [ ! -d node_modules ] || [ ! -d node_modules/@astrojs/node ] || [ ! -d node_modules/server-destroy ]; then + npm ci --no-fund --no-audit + fi + + sqlite3 "${dataDir}/dev.db" < ${./schema.sql} + + npm run prisma:generate + if [ ! -f dist/server/entry.mjs ]; then + npm run build + fi + ''; + + serviceConfig = { + Type = "simple"; + User = "thegeneralist"; + Group = "users"; + StateDirectory = "calorie-tracker"; + StateDirectoryMode = "0750"; + WorkingDirectory = appDir; + ExecStart = "${pkgs.nodejs_22}/bin/node ${appDir}/dist/server/entry.mjs"; + KillMode = "mixed"; + Restart = "always"; + RestartSec = 5; + }; + }; + + # services.nginx.virtualHosts.${domain} = ssl // { + # locations."/" = { + # proxyPass = "http://127.0.0.1:${toString port}"; + # proxyWebsockets = true; + # recommendedProxySettings = true; + # extraConfig = '' + # proxy_read_timeout 300s; + # proxy_send_timeout 300s; + # ''; + # }; + # }; +} diff --git a/hosts/thegeneralist-central/calorie-tracker/schema.sql b/hosts/thegeneralist-central/calorie-tracker/schema.sql new file mode 100644 index 0000000..7c2c1a8 --- /dev/null +++ b/hosts/thegeneralist-central/calorie-tracker/schema.sql @@ -0,0 +1,180 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL UNIQUE, + "email" TEXT NOT NULL UNIQUE, + "passwordHash" TEXT NOT NULL, + "isAdmin" INTEGER NOT NULL DEFAULT 0, + "emailVerifiedAt" DATETIME, + "locale" TEXT NOT NULL DEFAULT 'en', + "dayCutoffMinutes" INTEGER NOT NULL DEFAULT 0, + "weekStartsOn" INTEGER NOT NULL DEFAULT 1, + "scheduledDeletionAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tokenHash" TEXT NOT NULL UNIQUE, + "rememberDevice" INTEGER NOT NULL DEFAULT 1, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastActiveAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" DATETIME NOT NULL, + "recentAuthAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "Session_userId_idx" ON "Session"("userId"); + +CREATE TABLE IF NOT EXISTS "Goal" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL UNIQUE, + "type" TEXT NOT NULL DEFAULT 'MAINTAIN_WEIGHT', + "calorieFormula" TEXT NOT NULL DEFAULT 'MIFFLIN_ST_JEOR', + "dailyCalorieTarget" INTEGER, + "proteinGramsTarget" INTEGER, + "carbsGramsTarget" INTEGER, + "fatGramsTarget" INTEGER, + "adaptiveSuggestions" INTEGER NOT NULL DEFAULT 1, + "requiresApproval" INTEGER NOT NULL DEFAULT 1, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS "UserSettings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL UNIQUE, + "waterGoalLiters" REAL NOT NULL DEFAULT 2, + "useMetricDistance" INTEGER NOT NULL DEFAULT 1, + "precisionMode" TEXT NOT NULL DEFAULT 'BASIC', + "weekStartConfigurable" INTEGER NOT NULL DEFAULT 1, + "remindersEnabled" INTEGER NOT NULL DEFAULT 1, + "reminderQuietHoursStart" TEXT DEFAULT '22:00', + "reminderQuietHoursEnd" TEXT DEFAULT '07:00', + "pwaInstallPromptDismissed" INTEGER NOT NULL DEFAULT 0, + "pwaUpdateToastEnabled" INTEGER NOT NULL DEFAULT 1, + "aiPhotoEstimationEnabled" INTEGER NOT NULL DEFAULT 0, + "aiPhotoConsentAt" DATETIME, + "aiPhotoConsentPolicy" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS "Product" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ownerUserId" TEXT, + "name" TEXT NOT NULL, + "brand" TEXT, + "barcode" TEXT, + "qrCode" TEXT, + "region" TEXT, + "packageSizeLabel" TEXT, + "servingSizeLabel" TEXT, + "calories" REAL, + "protein" REAL, + "carbs" REAL, + "fat" REAL, + "source" TEXT NOT NULL DEFAULT 'MANUAL', + "isAiEstimated" INTEGER NOT NULL DEFAULT 0, + "isGlobal" INTEGER NOT NULL DEFAULT 0, + "publicationStatus" TEXT NOT NULL DEFAULT 'LOCAL_ONLY', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "publishedAt" DATETIME, + FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "Product_ownerUserId_idx" ON "Product"("ownerUserId"); +CREATE INDEX IF NOT EXISTS "Product_isGlobal_publicationStatus_idx" ON "Product"("isGlobal", "publicationStatus"); +CREATE INDEX IF NOT EXISTS "Product_barcode_brand_region_idx" ON "Product"("barcode", "brand", "region"); + +CREATE TABLE IF NOT EXISTS "ProductSubmission" ( + "id" TEXT NOT NULL PRIMARY KEY, + "productId" TEXT NOT NULL, + "submittedById" TEXT NOT NULL, + "reviewedById" TEXT, + "labelPhotoUrl" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "reason" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewedAt" DATETIME, + FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY ("submittedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "ProductSubmission_productId_status_idx" ON "ProductSubmission"("productId", "status"); + +CREATE TABLE IF NOT EXISTS "ProductContribution" ( + "id" TEXT NOT NULL PRIMARY KEY, + "productId" TEXT NOT NULL, + "contributorId" TEXT NOT NULL, + "payloadJson" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewedAt" DATETIME, + "reviewedById" TEXT, + FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY ("contributorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "ProductContribution_productId_status_idx" ON "ProductContribution"("productId", "status"); + +CREATE TABLE IF NOT EXISTS "ModerationAuditLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "actorUserId" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "reason" TEXT, + "beforeJson" TEXT, + "afterJson" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "ModerationAuditLog_targetType_targetId_idx" ON "ModerationAuditLog"("targetType", "targetId"); + +CREATE TABLE IF NOT EXISTS "MealEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "productId" TEXT, + "mealType" TEXT NOT NULL, + "consumedAt" DATETIME NOT NULL, + "quantityValue" REAL NOT NULL, + "quantityUnit" TEXT NOT NULL, + "calories" REAL NOT NULL, + "protein" REAL NOT NULL, + "carbs" REAL NOT NULL, + "fat" REAL NOT NULL, + "snapshotJson" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "MealEntry_userId_consumedAt_idx" ON "MealEntry"("userId", "consumedAt"); + +CREATE TABLE IF NOT EXISTS "WaterEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "amountMl" INTEGER NOT NULL, + "consumedAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "WaterEntry_userId_consumedAt_idx" ON "WaterEntry"("userId", "consumedAt"); + +CREATE TABLE IF NOT EXISTS "ActivityEntry" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "method" TEXT NOT NULL, + "durationMinutes" INTEGER, + "distanceKm" REAL, + "intensity" REAL, + "caloriesBurned" REAL NOT NULL, + "startedAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE +); +CREATE INDEX IF NOT EXISTS "ActivityEntry_userId_startedAt_idx" ON "ActivityEntry"("userId", "startedAt"); diff --git a/hosts/thegeneralist-central/configuration.nix b/hosts/thegeneralist-central/configuration.nix index 474339a..ba6f7e7 100644 --- a/hosts/thegeneralist-central/configuration.nix +++ b/hosts/thegeneralist-central/configuration.nix @@ -17,6 +17,7 @@ ./cache ./archive ./forgejo + ./calorie-tracker ]; age.secrets.password.file = ./password.age; diff --git a/hosts/thegeneralist-central/site.nix b/hosts/thegeneralist-central/site.nix index 538f1ad..a45a3ab 100644 --- a/hosts/thegeneralist-central/site.nix +++ b/hosts/thegeneralist-central/site.nix @@ -1,7 +1,7 @@ { config, pkgs, ... }: let domain = "thegeneralist01.com"; - family_domain = builtins.getEnv "FAMILY_DOMAIN"; + family_domain = builtins.getEnv "FAMILY_DOMAIN"; ssl = { quic = true; @@ -101,6 +101,7 @@ in "thegeneralist01.com" = "http://localhost:80"; "www.thegeneralist01.com" = "http://localhost:80"; "cache.thegeneralist01.com" = "http://localhost:80"; + "calorie.thegeneralist01.com" = "http://localhost:4322"; "git.thegeneralist01.com" = "http://localhost:3000"; }; default = "http_status:404";