diff --git a/.env.example b/.env.example
index eb4d82f..1b40386 100644
--- a/.env.example
+++ b/.env.example
@@ -1,29 +1,80 @@
+# =============================================================================
+# Hellth Hub - Beispiel-Umgebungsvariablen
+# Kopiere diese Datei nach `.env` und ersetze alle Secrets durch echte Werte.
+# Verwendung der Variablen ist in turbo.json deklariert; alle hier gelisteten
+# Werte werden von packages/api, packages/db oder apps/admin gelesen.
+# =============================================================================
+
+# -----------------------------------------------------------------------------
# Datenbank
+# -----------------------------------------------------------------------------
DATABASE_URL=postgresql://hellth:hellth_dev@localhost:5432/hellth_hub
-
-# Better Auth, in Produktion durch starkes Secret ersetzen
-BETTER_AUTH_SECRET=change-me-in-production-use-openssl-rand-base64-32
-BETTER_AUTH_BASE_URL=http://localhost:3002
-PASSKEY_RP_NAME=Hellth Hub
-
-# API
-API_PORT=3002
-TRUSTED_ORIGINS=http://localhost:3001
-NEXT_PUBLIC_API_URL=http://localhost:3002
-TRUST_PROXY_FORWARD_HEADERS=false
+# `true` erzwingt SSL gegenueber Postgres. In Produktion empfohlen.
DATABASE_REQUIRE_SSL=false
+# Wenn `true`, fuehrt die API beim Start automatisch ausstehende Migrationen aus.
ENABLE_DB_MIGRATIONS=false
-# Plattform-Admin, kommasepariert
+# -----------------------------------------------------------------------------
+# Better Auth (Login, Session-Cookies, 2FA)
+# -----------------------------------------------------------------------------
+# In Produktion durch starkes Secret ersetzen, z. B. `openssl rand -base64 32`.
+BETTER_AUTH_SECRET=change-me-in-production-use-openssl-rand-base64-32
+# Basis-URL fuer Auth-Endpoints und Passkey-Origins. In Produktion zwingend https://.
+BETTER_AUTH_BASE_URL=http://localhost:3002
+# Wenn `true`, oeffnet sich Self-Service-SignUp (nur fuer Seed-Skripte aktivieren).
+ALLOW_SEED_SIGNUP=false
+
+# -----------------------------------------------------------------------------
+# Passkeys (WebAuthn)
+# -----------------------------------------------------------------------------
+PASSKEY_RP_ID=localhost
+PASSKEY_RP_NAME=Hellth Hub
+
+# -----------------------------------------------------------------------------
+# API-Server
+# -----------------------------------------------------------------------------
+API_PORT=3002
+# Kommaseparierte Origin-Liste, die CORS akzeptieren soll.
+TRUSTED_ORIGINS=http://localhost:3001
+# Hinter Reverse-Proxy: `true`, damit X-Forwarded-Proto fuer HTTPS-Pruefung gelesen wird.
+TRUST_PROXY_FORWARD_HEADERS=false
+# Optional: Basis-URL fuer hochgeladene Assets (Fallback: BETTER_AUTH_BASE_URL).
+PUBLIC_ASSETS_BASE_URL=
+# Wenn `true`, schreibt der Health-Router Audit-Eintraege ins Konsolen-Log.
+HEALTH_AUDIT_LOGS=false
+
+# -----------------------------------------------------------------------------
+# Admin-App (Next.js)
+# -----------------------------------------------------------------------------
+NEXT_PUBLIC_API_URL=http://localhost:3002
+# Optional: URL fuer den Hilfe-Link in der UI.
+NEXT_PUBLIC_HELP_URL=
+
+# -----------------------------------------------------------------------------
+# Plattform-Admin-Zugriff
+# -----------------------------------------------------------------------------
+# Server-Seite: berechtigt zusaetzlich zu role=admin in der DB.
PLATFORM_ADMIN_EMAILS=admin@domain.tld
+# Frontend-Seite: gleiche Liste, fuer UI-Sichtbarkeit von Admin-Bereichen.
NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS=admin@domain.tld
-# Initialer Admin-User
+# -----------------------------------------------------------------------------
+# Initialer Admin-User (vom seed:admin-Script verwendet)
+# -----------------------------------------------------------------------------
ADMIN_EMAIL=admin@domain.tld
ADMIN_INITIAL_PASSWORD=please-set-a-strong-password-with-16-plus-chars
-# Wearables: Fitbit OAuth, optional
+# -----------------------------------------------------------------------------
+# Demo-Daten (optional, vom seed:demo-recipes-Script verwendet)
+# -----------------------------------------------------------------------------
+# Wenn gesetzt, werden Demo-Rezepte diesem Benutzer zugeordnet.
+DEMO_RECIPE_OWNER_EMAIL=
+
+# -----------------------------------------------------------------------------
+# Wearables: Fitbit OAuth (optional, derzeit vorbereitet)
+# -----------------------------------------------------------------------------
FITBIT_CLIENT_ID=
FITBIT_CLIENT_SECRET=
FITBIT_REDIRECT_URI=http://localhost:3002/api/wearables/fitbit/callback
+# 32-Byte Base64-Key zur Verschluesselung von Wearable-Access-Tokens in der DB.
WEARABLE_TOKEN_ENCRYPTION_KEY=change-me-32-byte-base64-key
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..861d34e
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,68 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ verify:
+ name: Lint, Typecheck, Test, Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.30.1
+ run_install: false
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ # Tests laden .env via node --env-file (siehe packages/api/package.json).
+ # Im CI ist sie nicht vorhanden, also legen wir eine minimale CI-Variante an.
+ # Werte sind Dummies: Tests mocken DB und Auth, die Module-Init-Checks brauchen
+ # nur, dass die Variablen ueberhaupt gesetzt sind.
+ - name: Prepare CI .env
+ run: |
+ cat > .env <<'EOF'
+ DATABASE_URL=postgresql://ci:ci@localhost:5432/ci
+ BETTER_AUTH_SECRET=ci-test-secret-not-used-for-real-please-replace-32
+ BETTER_AUTH_BASE_URL=http://localhost:3002
+ PASSKEY_RP_ID=localhost
+ PASSKEY_RP_NAME=Hellth Hub CI
+ API_PORT=3002
+ TRUSTED_ORIGINS=http://localhost:3001
+ NEXT_PUBLIC_API_URL=http://localhost:3002
+ PLATFORM_ADMIN_EMAILS=ci@example.org
+ NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS=ci@example.org
+ ADMIN_EMAIL=ci@example.org
+ ADMIN_INITIAL_PASSWORD=ci-test-password-not-used-32-chars
+ ENABLE_DB_MIGRATIONS=false
+ ALLOW_SEED_SIGNUP=false
+ HEALTH_AUDIT_LOGS=false
+ EOF
+
+ - name: Lint
+ run: pnpm lint
+
+ - name: Typecheck
+ run: pnpm typecheck
+
+ - name: Test
+ run: pnpm test
+
+ - name: Build admin
+ env:
+ NEXT_PUBLIC_API_URL: http://localhost:3002
+ run: pnpm --filter admin build
diff --git a/.gitignore b/.gitignore
index f6a4c14..6bda3f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ build/
.turbo/
.cache/
coverage/
+*.tsbuildinfo
# Logs
*.log
diff --git a/apps/admin/eslint.config.mjs b/apps/admin/eslint.config.mjs
new file mode 100644
index 0000000..187dd16
--- /dev/null
+++ b/apps/admin/eslint.config.mjs
@@ -0,0 +1,24 @@
+import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
+import nextTypescript from "eslint-config-next/typescript";
+
+// Diese Regeln sind als Tech-Debt markiert: sie sollen sichtbar bleiben (warn),
+// aber CI nicht rot machen, solange der bestehende Code sie noch verletzt.
+// Wenn die offenen Faelle nach und nach gefixt sind: hier wieder auf "error" hochziehen.
+const techDebtWarnings = {
+ "react-hooks/set-state-in-effect": "warn",
+ "react-hooks/preserve-manual-memoization": "warn",
+ "@typescript-eslint/no-unused-vars": "warn",
+};
+
+const config = [
+ {
+ ignores: [".next/**", "node_modules/**", "out/**", "public/**"],
+ },
+ ...nextCoreWebVitals,
+ ...nextTypescript,
+ {
+ rules: techDebtWarnings,
+ },
+];
+
+export default config;
diff --git a/apps/admin/lib/hellth-data.test.ts b/apps/admin/lib/hellth-data.test.ts
index a135718..9ca7a2e 100644
--- a/apps/admin/lib/hellth-data.test.ts
+++ b/apps/admin/lib/hellth-data.test.ts
@@ -133,6 +133,7 @@ test("classifyRecipe detects vegetarian vegan and macro criteria", () => {
id: "vegan",
name: "Tofu Bowl",
portions: 1,
+ minutes: 10,
ingredients: [
{ foodId: "tofu", amountG: 200 },
{ foodId: "oats", amountG: 50 },
@@ -142,6 +143,7 @@ test("classifyRecipe detects vegetarian vegan and macro criteria", () => {
id: "chicken",
name: "Hähnchen Bowl",
portions: 1,
+ minutes: 15,
ingredients: [{ foodId: "chicken", amountG: 180 }],
};
diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts
index c4b7818..9edff1c 100644
--- a/apps/admin/next-env.d.ts
+++ b/apps/admin/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 8d425f2..f1b8473 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -7,7 +7,8 @@
"build": "next build",
"start": "next start --port 3001",
"test": "node --import tsx --test lib/**/*.test.ts",
- "lint": "eslint"
+ "lint": "eslint",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/passkey": "^1.4.18",
diff --git a/packages/api/package.json b/packages/api/package.json
index b05b909..e6dc8a2 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -11,6 +11,7 @@
"scripts": {
"dev": "node --watch --import tsx src/index.ts",
"test": "node --env-file ../../.env --import tsx --test src/**/*.test.ts",
+ "typecheck": "tsc --noEmit",
"seed:admin": "node --import tsx src/scripts/seed-admin.ts",
"seed:health": "node --import tsx src/scripts/seed-health.ts",
"seed:demo-recipes": "node --import tsx src/scripts/seed-demo-recipes.ts"
diff --git a/packages/api/src/trpc/context.ts b/packages/api/src/trpc/context.ts
index 9ff44c9..378b97e 100644
--- a/packages/api/src/trpc/context.ts
+++ b/packages/api/src/trpc/context.ts
@@ -1,8 +1,37 @@
-import { db } from "@hellth/db";
+import { db, type Database } from "@hellth/db";
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { auth } from "../lib/auth";
import { isPlatformAdminUser, parseConfiguredAdminEmails } from "../lib/runtime-security";
+export type TwoFactorState = {
+ dbUser: {
+ email: string;
+ role: string;
+ twoFactorEnabled: boolean;
+ twoFactorRecoveryRequired: boolean;
+ } | undefined;
+ twoFactorRow: { enabled: boolean } | undefined;
+};
+
+async function loadTwoFactorState(database: Database, userId: string): Promise {
+ const [dbUser, twoFactorRow] = await Promise.all([
+ database.query.user.findFirst({
+ where: (row, { eq }) => eq(row.id, userId),
+ columns: {
+ email: true,
+ role: true,
+ twoFactorEnabled: true,
+ twoFactorRecoveryRequired: true,
+ },
+ }),
+ database.query.twoFactor.findFirst({
+ where: (row, { eq }) => eq(row.userId, userId),
+ columns: { enabled: true },
+ }),
+ ]);
+ return { dbUser, twoFactorRow };
+}
+
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({
headers: opts.req.headers,
@@ -18,6 +47,19 @@ export async function createContext(opts: FetchCreateContextFnOptions) {
configuredAdminEmails: platformAdminEmails,
});
+ // Lazy + per-Request gecached: 2FA-State wird nur geladen, wenn eine Middleware
+ // ihn anfordert, und innerhalb desselben tRPC-Batches nur einmal.
+ let twoFactorStatePromise: Promise | null = null;
+ const getTwoFactorState = (): Promise => {
+ if (!user) {
+ return Promise.resolve({ dbUser: undefined, twoFactorRow: undefined });
+ }
+ if (!twoFactorStatePromise) {
+ twoFactorStatePromise = loadTwoFactorState(db, user.id);
+ }
+ return twoFactorStatePromise;
+ };
+
return {
db,
headers: opts.req.headers,
@@ -25,6 +67,7 @@ export async function createContext(opts: FetchCreateContextFnOptions) {
user,
userRole,
isPlatformAdmin,
+ getTwoFactorState,
};
}
diff --git a/packages/api/src/trpc/init.ts b/packages/api/src/trpc/init.ts
index 45b3214..de76e5b 100644
--- a/packages/api/src/trpc/init.ts
+++ b/packages/api/src/trpc/init.ts
@@ -40,21 +40,9 @@ const enforceTwoFactorForPrivilegedUsers = t.middleware(async ({ ctx, next, path
return next();
}
- const dbUser = await ctx.db.query.user.findFirst({
- where: (row: any, { eq }: any) => eq(row.id, user.id),
- columns: {
- email: true,
- role: true,
- twoFactorEnabled: true,
- twoFactorRecoveryRequired: true,
- },
- });
- const twoFactorRow = await ctx.db.query.twoFactor.findFirst({
- where: (row: any, { eq }: any) => eq(row.userId, user.id),
- columns: {
- enabled: true,
- },
- });
+ // Lazy + per-Request gecached - bei tRPC-Batches mit mehreren protected Calls
+ // wird die 2FA-Status-Query nur einmal ausgefuehrt (siehe context.ts).
+ const { dbUser, twoFactorRow } = await ctx.getTwoFactorState();
const hasTwoFactor = !!dbUser?.twoFactorEnabled && !!twoFactorRow?.enabled && !dbUser?.twoFactorRecoveryRequired;
if (!hasTwoFactor) {
diff --git a/packages/api/src/trpc/routers/health.ts b/packages/api/src/trpc/routers/health.ts
index c0ff8b7..d802880 100644
--- a/packages/api/src/trpc/routers/health.ts
+++ b/packages/api/src/trpc/routers/health.ts
@@ -1,401 +1,44 @@
import { schema } from "@hellth/db";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
-import { randomUUID } from "node:crypto";
import { z } from "zod";
import { buildDailyCoachSignals } from "../../lib/health-coach";
import { platformProcedure, protectedProcedure, router } from "../init";
-
-const dateKeySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
-const portionUnitSchema = z.enum(["g", "ml", "tsp", "tbsp", "piece", "pack", "bottle", "cup", "slice"]);
-const mealTypeSchema = z.enum(["breakfast", "lunch", "dinner", "snack"]);
-const clubCategorySchema = z.enum(["walk", "run", "mobility", "strength", "bike", "other"]);
-const clubScopeSchema = z.enum(["local", "state", "nationwide"]);
-const clubPrivacyModeSchema = z.enum(["city", "state"]);
-
-const pointSettingDefaults = {
- recipeSubmissionPoints: 25,
- clubActivityDefaultPoints: 100,
-};
-
-const pointSettingsInput = z.object({
- recipeSubmissionPoints: z.number().int().min(0).max(10000),
- clubActivityDefaultPoints: z.number().int().min(0).max(10000),
- activityTemplates: z.array(z.object({
- id: z.string().min(1),
- defaultPoints: z.number().int().min(0).max(10000),
- })).default([]),
-});
-
-function id(prefix: string) {
- return `${prefix}-${randomUUID()}`;
-}
-
-function auditHealthMutation(ctx: { user?: { id?: string } | null }, action: string, metadata: Record = {}) {
- if (process.env.HEALTH_AUDIT_LOGS !== "true") return;
- console.info("[health]", {
- action,
- userId: ctx.user?.id ?? null,
- ...metadata,
- at: new Date().toISOString(),
- });
-}
-
-async function getPointSettings(db: any) {
- const row = await db.query.platformSettings.findFirst({
- where: (setting: any, { eq: rowEq }: any) => rowEq(setting.key, "hellth.points"),
- });
- if (!row?.value) return pointSettingDefaults;
- try {
- const parsed = JSON.parse(row.value) as Partial;
- return {
- recipeSubmissionPoints: Number(parsed.recipeSubmissionPoints) || pointSettingDefaults.recipeSubmissionPoints,
- clubActivityDefaultPoints: Number(parsed.clubActivityDefaultPoints) || pointSettingDefaults.clubActivityDefaultPoints,
- };
- } catch {
- return pointSettingDefaults;
- }
-}
-
-function numberString(value: number | string | null | undefined, fallback = 0) {
- if (value === null || value === undefined || value === "") return String(fallback);
- return String(value);
-}
-
-function startOfWeek(dateKey: string) {
- const date = new Date(`${dateKey}T12:00:00`);
- const day = date.getDay() === 0 ? 7 : date.getDay();
- date.setDate(date.getDate() - day + 1);
- return date.toISOString().slice(0, 10);
-}
-
-function addDays(dateKey: string, days: number) {
- const date = new Date(`${dateKey}T12:00:00`);
- date.setDate(date.getDate() + days);
- return date.toISOString().slice(0, 10);
-}
-
-function dateRange(endDate: string, days: number) {
- return Array.from({ length: days }, (_, index) => addDays(endDate, index - days + 1));
-}
-
-function calcRecipeCalories(
- recipe: { id: string; portions: number },
- ingredients: Array<{ recipeId: string; foodId: string; amountG: string | number }>,
- foods: Array<{ id: string; kcalPer100g: string | number }>,
-) {
- const foodById = new Map(foods.map((food) => [food.id, food]));
- const total = ingredients
- .filter((ingredient) => ingredient.recipeId === recipe.id)
- .reduce((sum, ingredient) => {
- const food = foodById.get(ingredient.foodId);
- if (!food) return sum;
- return sum + (Number(food.kcalPer100g ?? 0) * Number(ingredient.amountG ?? 0)) / 100;
- }, 0);
- return Math.round(total / Math.max(1, recipe.portions));
-}
-
-function normalizeSearch(value: string | null | undefined) {
- return (value ?? "").toLowerCase();
-}
-
-const meatWords = ["hähnchen", "huhn", "rind", "schwein", "hack", "speck", "salami", "schinken", "wurst", "pute", "truthahn", "fleisch"];
-const fishWords = ["fisch", "lachs", "thunfisch", "forelle", "kabeljau", "garnelen", "shrimps", "sardine"];
-const animalWords = [...meatWords, ...fishWords, "ei", "eier", "milch", "joghurt", "quark", "skyr", "käse", "sahne", "butter", "honig", "molke", "whey", "gelatine"];
-
-function foodText(food: { name: string; brand?: string | null; category?: string | null }) {
- return normalizeSearch(`${food.name} ${food.brand ?? ""} ${food.category ?? ""}`);
-}
-
-function hasAnyWord(text: string, words: string[]) {
- return words.some((word) => text.includes(word));
-}
-
-function classifyFoodsForDiet(foods: Array<{ name: string; brand?: string | null; category?: string | null }>) {
- const texts = foods.map(foodText);
- const hasMeat = texts.some((text) => hasAnyWord(text, meatWords));
- const hasFish = texts.some((text) => hasAnyWord(text, fishWords));
- const hasAnimal = texts.some((text) => hasAnyWord(text, animalWords));
- return {
- hasMeat,
- hasFish,
- hasAnimal,
- tags: [
- !hasAnimal ? "vegan" : undefined,
- hasAnimal && !hasMeat && !hasFish ? "vegetarisch" : undefined,
- hasMeat ? "mit Fleisch" : undefined,
- hasFish ? "mit Fisch" : undefined,
- ].filter(Boolean) as string[],
- };
-}
-
-function recipeNutrition(
- recipe: { id: string; portions: number },
- ingredients: Array<{ recipeId: string; foodId: string; amountG: string | number }>,
- foods: Array<{ id: string; kcalPer100g: string | number; proteinPer100g: string | number; carbsPer100g: string | number; fatPer100g: string | number }>,
-) {
- const foodById = new Map(foods.map((food) => [food.id, food]));
- const totals = ingredients
- .filter((ingredient) => ingredient.recipeId === recipe.id)
- .reduce((sum, ingredient) => {
- const food = foodById.get(ingredient.foodId);
- const factor = Number(ingredient.amountG ?? 0) / 100;
- if (!food) return sum;
- return {
- kcal: sum.kcal + Number(food.kcalPer100g ?? 0) * factor,
- protein: sum.protein + Number(food.proteinPer100g ?? 0) * factor,
- carbs: sum.carbs + Number(food.carbsPer100g ?? 0) * factor,
- fat: sum.fat + Number(food.fatPer100g ?? 0) * factor,
- };
- }, { kcal: 0, protein: 0, carbs: 0, fat: 0 });
- const divisor = Math.max(1, recipe.portions);
- const perPortion = {
- kcal: Math.round(totals.kcal / divisor),
- protein: Math.round((totals.protein / divisor) * 10) / 10,
- carbs: Math.round((totals.carbs / divisor) * 10) / 10,
- fat: Math.round((totals.fat / divisor) * 10) / 10,
- };
- const macroCalories = {
- carbs: perPortion.carbs * 4,
- protein: perPortion.protein * 4,
- fat: perPortion.fat * 9,
- };
- const macroTotal = Math.max(1, macroCalories.carbs + macroCalories.protein + macroCalories.fat);
- return {
- ...perPortion,
- distribution: {
- carbs: Math.round((macroCalories.carbs / macroTotal) * 100),
- protein: Math.round((macroCalories.protein / macroTotal) * 100),
- fat: Math.round((macroCalories.fat / macroTotal) * 100),
- },
- };
-}
-
-const foodPortionInput = z.object({
- id: z.string().optional(),
- label: z.string().trim().min(1).max(120),
- unit: portionUnitSchema,
- amount: z.number().positive(),
- gramsEquivalent: z.number().positive(),
-});
-
-const foodInput = z.object({
- id: z.string().optional(),
- name: z.string().trim().min(1).max(180),
- brand: z.string().trim().max(180).optional().nullable(),
- barcode: z.string().trim().max(80).optional().nullable(),
- category: z.string().trim().min(1).max(120).default("Sonstiges"),
- kcalPer100g: z.number().min(0).max(2000),
- proteinPer100g: z.number().min(0).max(300),
- carbsPer100g: z.number().min(0).max(300),
- fatPer100g: z.number().min(0).max(300),
- servingG: z.number().positive().max(10000).default(100),
- preferredUnit: portionUnitSchema.default("g"),
- densityGPerMl: z.number().positive().max(5).optional().nullable(),
- source: z.enum(["seed", "manual", "openfoodfacts", "ocr", "community"]).default("manual"),
- availableEverywhere: z.boolean().default(true),
- portions: z.array(foodPortionInput).default([]),
-});
-
-const storeDepartmentInput = z.object({
- id: z.string().optional(),
- name: z.string().trim().min(1).max(120),
- sortOrder: z.number().int().min(0).max(1000),
-});
-
-const storeInput = z.object({
- id: z.string().optional(),
- name: z.string().trim().min(1).max(180),
- address: z.string().trim().max(300).optional().nullable(),
- placeId: z.string().trim().max(240).optional().nullable(),
- latitude: z.number().min(-90).max(90).optional().nullable(),
- longitude: z.number().min(-180).max(180).optional().nullable(),
- favorite: z.boolean().default(false),
- departments: z.array(storeDepartmentInput).min(1).max(80),
-});
-
-const storeFavoriteInput = z.object({
- storeId: z.string().min(1),
- favorite: z.boolean(),
-});
-
-const storeChangeRequestInput = z.object({
- storeId: z.string().min(1).optional().nullable(),
- requestType: z.enum(["new_store", "structure_update", "location_update"]).default("structure_update"),
- title: z.string().trim().min(1).max(180),
- note: z.string().max(2000).optional().nullable(),
- store: z.object({
- name: z.string().trim().min(1).max(180),
- address: z.string().trim().max(300).optional().nullable(),
- placeId: z.string().trim().max(240).optional().nullable(),
- latitude: z.number().min(-90).max(90).optional().nullable(),
- longitude: z.number().min(-180).max(180).optional().nullable(),
- }),
- departments: z.array(storeDepartmentInput).min(1).max(80),
-});
-
-const storeChangeReviewInput = z.object({
- id: z.string().min(1),
- action: z.enum(["accept", "reject"]),
- reviewNote: z.string().max(1000).optional().nullable(),
-});
-
-const nutritionSourceInput = z.object({
- foodId: z.string().min(1),
- provider: z.string().trim().min(1).max(80),
- providerFoodId: z.string().trim().max(180).optional().nullable(),
- name: z.string().trim().min(1).max(180),
- brand: z.string().trim().max(180).optional().nullable(),
- url: z.string().url().max(1024).optional().nullable(),
- kcalPer100g: z.number().min(0).max(2000),
- proteinPer100g: z.number().min(0).max(300),
- carbsPer100g: z.number().min(0).max(300),
- fatPer100g: z.number().min(0).max(300),
- servingG: z.number().positive().max(10000).optional().nullable(),
- confidence: z.number().int().min(0).max(100).default(70),
- raw: z.record(z.unknown()).default({}),
-});
-
-function averageNutritionSources(sources: Array<{
- kcalPer100g: string | number;
- proteinPer100g: string | number;
- carbsPer100g: string | number;
- fatPer100g: string | number;
-}>) {
- const count = sources.length || 1;
- return {
- kcalPer100g: Math.round(sources.reduce((sum, source) => sum + Number(source.kcalPer100g || 0), 0) / count),
- proteinPer100g: Number((sources.reduce((sum, source) => sum + Number(source.proteinPer100g || 0), 0) / count).toFixed(1)),
- carbsPer100g: Number((sources.reduce((sum, source) => sum + Number(source.carbsPer100g || 0), 0) / count).toFixed(1)),
- fatPer100g: Number((sources.reduce((sum, source) => sum + Number(source.fatPer100g || 0), 0) / count).toFixed(1)),
- };
-}
-
-function normalizeOpenFoodFactsProduct(product: {
- code?: string;
- product_name?: string;
- brands?: string;
- categories?: string;
- serving_quantity?: number | string;
- nutriments?: Record;
-}) {
- const nutriments = product.nutriments ?? {};
- return {
- provider: "openfoodfacts",
- providerFoodId: product.code ?? null,
- name: product.product_name || "Unbenanntes Produkt",
- brand: product.brands?.split(",")[0]?.trim() || null,
- category: product.categories?.split(",")[0]?.trim() || "Supermarkt",
- url: product.code ? `https://world.openfoodfacts.org/product/${product.code}` : "https://world.openfoodfacts.org/",
- kcalPer100g: Number(nutriments["energy-kcal_100g"] ?? 0),
- proteinPer100g: Number(nutriments.proteins_100g ?? 0),
- carbsPer100g: Number(nutriments.carbohydrates_100g ?? 0),
- fatPer100g: Number(nutriments.fat_100g ?? 0),
- servingG: Number(product.serving_quantity ?? 100) || 100,
- confidence: 75,
- raw: product as Record,
- };
-}
-
-const recipeIngredientInput = z.object({
- id: z.string().optional(),
- foodId: z.string().min(1),
- amountG: z.number().positive(),
- displayAmount: z.number().positive().optional().nullable(),
- displayUnit: portionUnitSchema.optional().nullable(),
- portionId: z.string().optional().nullable(),
- sortOrder: z.number().int().min(0).default(0),
-});
-
-const recipeInput = z.object({
- id: z.string().optional(),
- name: z.string().trim().min(1).max(180),
- portions: z.number().int().min(1).max(100).default(1),
- minutes: z.number().int().min(0).max(1440).default(0),
- notes: z.string().max(4000).optional().nullable(),
- mealPrepDays: z.number().int().min(0).max(14).default(0),
- isPublic: z.boolean().default(false),
- ingredients: z.array(recipeIngredientInput).min(1).max(100),
-});
-
-const recipeSubmissionStatusSchema = z.enum(["pending", "accepted", "rejected"]);
-
-const recipeSubmissionReviewInput = z.object({
- id: z.string().min(1),
- action: z.enum(["accept", "reject"]),
- reviewNote: z.string().max(1000).optional().nullable(),
-});
-
-const dietPreferenceSchema = z.enum(["vegan", "vegetarian", "meat", "fish"]);
-const meatFishFrequencySchema = z.enum(["none", "once_per_day", "flexible"]);
-
-const goalsInput = z.object({
- drinkMl: z.number().int().min(0).max(10000),
- calories: z.number().int().min(800).max(6000),
- carbsG: z.number().int().min(0).max(800),
- fatG: z.number().int().min(0).max(400),
- proteinG: z.number().int().min(0).max(400),
- weightKg: z.number().positive().max(500).optional().nullable(),
- fastingHours: z.number().int().min(0).max(24),
- weeklyCalories: z.number().int().min(0).max(50000).optional().nullable(),
- sportAddsCalories: z.boolean().default(true),
-});
-
-const profileInput = z.object({
- goal: z.enum(["lose_weight", "maintain", "build_muscle", "healthier"]),
- age: z.number().int().min(12).max(120).optional().nullable(),
- heightCm: z.number().positive().max(260).optional().nullable(),
- currentWeightKg: z.number().positive().max(500).optional().nullable(),
- targetWeightKg: z.number().positive().max(500).optional().nullable(),
- activityLevel: z.enum(["low", "moderate", "active"]),
- dietStyle: z.enum(["mixed", "vegetarian", "vegan", "low_carb", "flexible"]),
- dietPreferences: z.array(dietPreferenceSchema).min(1).default(["vegan", "vegetarian", "meat", "fish"]),
- mealsPerDay: z.number().int().min(1).max(8),
- cooksPerDay: z.number().int().min(1).max(4).default(1),
- preferredMealPrepDays: z.number().int().min(0).max(7).default(0),
- meatFishFrequency: meatFishFrequencySchema.default("flexible"),
- cookingTime: z.enum(["low", "medium", "high"]),
- budgetFocus: z.boolean(),
- excludedFoods: z.string().max(2000).optional().nullable(),
- biggestChallenge: z.string().max(2000).optional().nullable(),
- medicalNote: z.boolean(),
- completed: z.boolean().default(true),
-});
-
-const foodPreferenceInput = z.object({
- foodId: z.string().min(1),
- vote: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
-});
-
-const recipeRecommendationInput = z.object({
- query: z.string().trim().max(120).optional().default(""),
- mealPrepDays: z.number().int().min(0).max(7).optional(),
- cooksPerDay: z.number().int().min(1).max(4).optional(),
- limit: z.number().int().min(1).max(50).default(20),
-});
-
-const clubProfileInput = z.object({
- state: z.string().trim().min(1).max(80),
- city: z.string().trim().max(120).optional().nullable(),
- latitude: z.number().min(-90).max(90).optional().nullable(),
- longitude: z.number().min(-180).max(180).optional().nullable(),
- privacyMode: clubPrivacyModeSchema.default("state"),
-});
-
-const clubActivityInput = z.object({
- title: z.string().trim().min(1).max(180),
- category: clubCategorySchema,
- scope: clubScopeSchema,
- state: z.string().trim().max(80).optional().nullable(),
- city: z.string().trim().max(120).optional().nullable(),
- latitude: z.number().min(-90).max(90).optional().nullable(),
- longitude: z.number().min(-180).max(180).optional().nullable(),
- locationLabel: z.string().trim().max(120).optional().nullable(),
- startsAt: z.string().datetime(),
- durationMinutes: z.number().int().min(1).max(1440),
- points: z.number().int().min(0).max(10000),
- note: z.string().max(2000).optional().nullable(),
-});
+import {
+ addDays,
+ auditHealthMutation,
+ averageNutritionSources,
+ calcRecipeCalories,
+ classifyFoodsForDiet,
+ clubActivityInput,
+ clubProfileInput,
+ dateKeySchema,
+ dateRange,
+ foodInput,
+ foodPreferenceInput,
+ foodText,
+ getPointSettings,
+ goalsInput,
+ hasAnyWord,
+ id,
+ mealTypeSchema,
+ normalizeOpenFoodFactsProduct,
+ normalizeSearch,
+ numberString,
+ nutritionSourceInput,
+ pointSettingsInput,
+ profileInput,
+ recipeInput,
+ recipeNutrition,
+ recipeRecommendationInput,
+ recipeSubmissionReviewInput,
+ recipeSubmissionStatusSchema,
+ startOfWeek,
+ storeChangeRequestInput,
+ storeChangeReviewInput,
+ storeFavoriteInput,
+ storeInput,
+} from "./health/_shared";
export const healthRouter = router({
pointDefaults: protectedProcedure.query(async ({ ctx }) => getPointSettings(ctx.db)),
diff --git a/packages/api/src/trpc/routers/health/_shared.ts b/packages/api/src/trpc/routers/health/_shared.ts
new file mode 100644
index 0000000..7ceb34e
--- /dev/null
+++ b/packages/api/src/trpc/routers/health/_shared.ts
@@ -0,0 +1,400 @@
+import type { Database } from "@hellth/db";
+import { randomUUID } from "node:crypto";
+import { z } from "zod";
+
+// ---------------------------------------------------------------------------
+// Zod schemas (shared between health sub-routers)
+// ---------------------------------------------------------------------------
+
+export const dateKeySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
+export const portionUnitSchema = z.enum(["g", "ml", "tsp", "tbsp", "piece", "pack", "bottle", "cup", "slice"]);
+export const mealTypeSchema = z.enum(["breakfast", "lunch", "dinner", "snack"]);
+export const clubCategorySchema = z.enum(["walk", "run", "mobility", "strength", "bike", "other"]);
+export const clubScopeSchema = z.enum(["local", "state", "nationwide"]);
+export const clubPrivacyModeSchema = z.enum(["city", "state"]);
+export const dietPreferenceSchema = z.enum(["vegan", "vegetarian", "meat", "fish"]);
+export const meatFishFrequencySchema = z.enum(["none", "once_per_day", "flexible"]);
+export const recipeSubmissionStatusSchema = z.enum(["pending", "accepted", "rejected"]);
+
+export const pointSettingDefaults = {
+ recipeSubmissionPoints: 25,
+ clubActivityDefaultPoints: 100,
+};
+
+export const pointSettingsInput = z.object({
+ recipeSubmissionPoints: z.number().int().min(0).max(10000),
+ clubActivityDefaultPoints: z.number().int().min(0).max(10000),
+ activityTemplates: z.array(z.object({
+ id: z.string().min(1),
+ defaultPoints: z.number().int().min(0).max(10000),
+ })).default([]),
+});
+
+export const foodPortionInput = z.object({
+ id: z.string().optional(),
+ label: z.string().trim().min(1).max(120),
+ unit: portionUnitSchema,
+ amount: z.number().positive(),
+ gramsEquivalent: z.number().positive(),
+});
+
+export const foodInput = z.object({
+ id: z.string().optional(),
+ name: z.string().trim().min(1).max(180),
+ brand: z.string().trim().max(180).optional().nullable(),
+ barcode: z.string().trim().max(80).optional().nullable(),
+ category: z.string().trim().min(1).max(120).default("Sonstiges"),
+ kcalPer100g: z.number().min(0).max(2000),
+ proteinPer100g: z.number().min(0).max(300),
+ carbsPer100g: z.number().min(0).max(300),
+ fatPer100g: z.number().min(0).max(300),
+ servingG: z.number().positive().max(10000).default(100),
+ preferredUnit: portionUnitSchema.default("g"),
+ densityGPerMl: z.number().positive().max(5).optional().nullable(),
+ source: z.enum(["seed", "manual", "openfoodfacts", "ocr", "community"]).default("manual"),
+ availableEverywhere: z.boolean().default(true),
+ portions: z.array(foodPortionInput).default([]),
+});
+
+export const storeDepartmentInput = z.object({
+ id: z.string().optional(),
+ name: z.string().trim().min(1).max(120),
+ sortOrder: z.number().int().min(0).max(1000),
+});
+
+export const storeInput = z.object({
+ id: z.string().optional(),
+ name: z.string().trim().min(1).max(180),
+ address: z.string().trim().max(300).optional().nullable(),
+ placeId: z.string().trim().max(240).optional().nullable(),
+ latitude: z.number().min(-90).max(90).optional().nullable(),
+ longitude: z.number().min(-180).max(180).optional().nullable(),
+ favorite: z.boolean().default(false),
+ departments: z.array(storeDepartmentInput).min(1).max(80),
+});
+
+export const storeFavoriteInput = z.object({
+ storeId: z.string().min(1),
+ favorite: z.boolean(),
+});
+
+export const storeChangeRequestInput = z.object({
+ storeId: z.string().min(1).optional().nullable(),
+ requestType: z.enum(["new_store", "structure_update", "location_update"]).default("structure_update"),
+ title: z.string().trim().min(1).max(180),
+ note: z.string().max(2000).optional().nullable(),
+ store: z.object({
+ name: z.string().trim().min(1).max(180),
+ address: z.string().trim().max(300).optional().nullable(),
+ placeId: z.string().trim().max(240).optional().nullable(),
+ latitude: z.number().min(-90).max(90).optional().nullable(),
+ longitude: z.number().min(-180).max(180).optional().nullable(),
+ }),
+ departments: z.array(storeDepartmentInput).min(1).max(80),
+});
+
+export const storeChangeReviewInput = z.object({
+ id: z.string().min(1),
+ action: z.enum(["accept", "reject"]),
+ reviewNote: z.string().max(1000).optional().nullable(),
+});
+
+export const nutritionSourceInput = z.object({
+ foodId: z.string().min(1),
+ provider: z.string().trim().min(1).max(80),
+ providerFoodId: z.string().trim().max(180).optional().nullable(),
+ name: z.string().trim().min(1).max(180),
+ brand: z.string().trim().max(180).optional().nullable(),
+ url: z.string().url().max(1024).optional().nullable(),
+ kcalPer100g: z.number().min(0).max(2000),
+ proteinPer100g: z.number().min(0).max(300),
+ carbsPer100g: z.number().min(0).max(300),
+ fatPer100g: z.number().min(0).max(300),
+ servingG: z.number().positive().max(10000).optional().nullable(),
+ confidence: z.number().int().min(0).max(100).default(70),
+ raw: z.record(z.unknown()).default({}),
+});
+
+export const recipeIngredientInput = z.object({
+ id: z.string().optional(),
+ foodId: z.string().min(1),
+ amountG: z.number().positive(),
+ displayAmount: z.number().positive().optional().nullable(),
+ displayUnit: portionUnitSchema.optional().nullable(),
+ portionId: z.string().optional().nullable(),
+ sortOrder: z.number().int().min(0).default(0),
+});
+
+export const recipeInput = z.object({
+ id: z.string().optional(),
+ name: z.string().trim().min(1).max(180),
+ portions: z.number().int().min(1).max(100).default(1),
+ minutes: z.number().int().min(0).max(1440).default(0),
+ notes: z.string().max(4000).optional().nullable(),
+ mealPrepDays: z.number().int().min(0).max(14).default(0),
+ isPublic: z.boolean().default(false),
+ ingredients: z.array(recipeIngredientInput).min(1).max(100),
+});
+
+export const recipeSubmissionReviewInput = z.object({
+ id: z.string().min(1),
+ action: z.enum(["accept", "reject"]),
+ reviewNote: z.string().max(1000).optional().nullable(),
+});
+
+export const goalsInput = z.object({
+ drinkMl: z.number().int().min(0).max(10000),
+ calories: z.number().int().min(800).max(6000),
+ carbsG: z.number().int().min(0).max(800),
+ fatG: z.number().int().min(0).max(400),
+ proteinG: z.number().int().min(0).max(400),
+ weightKg: z.number().positive().max(500).optional().nullable(),
+ fastingHours: z.number().int().min(0).max(24),
+ weeklyCalories: z.number().int().min(0).max(50000).optional().nullable(),
+ sportAddsCalories: z.boolean().default(true),
+});
+
+export const profileInput = z.object({
+ goal: z.enum(["lose_weight", "maintain", "build_muscle", "healthier"]),
+ age: z.number().int().min(12).max(120).optional().nullable(),
+ heightCm: z.number().positive().max(260).optional().nullable(),
+ currentWeightKg: z.number().positive().max(500).optional().nullable(),
+ targetWeightKg: z.number().positive().max(500).optional().nullable(),
+ activityLevel: z.enum(["low", "moderate", "active"]),
+ dietStyle: z.enum(["mixed", "vegetarian", "vegan", "low_carb", "flexible"]),
+ dietPreferences: z.array(dietPreferenceSchema).min(1).default(["vegan", "vegetarian", "meat", "fish"]),
+ mealsPerDay: z.number().int().min(1).max(8),
+ cooksPerDay: z.number().int().min(1).max(4).default(1),
+ preferredMealPrepDays: z.number().int().min(0).max(7).default(0),
+ meatFishFrequency: meatFishFrequencySchema.default("flexible"),
+ cookingTime: z.enum(["low", "medium", "high"]),
+ budgetFocus: z.boolean(),
+ excludedFoods: z.string().max(2000).optional().nullable(),
+ biggestChallenge: z.string().max(2000).optional().nullable(),
+ medicalNote: z.boolean(),
+ completed: z.boolean().default(true),
+});
+
+export const foodPreferenceInput = z.object({
+ foodId: z.string().min(1),
+ vote: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
+});
+
+export const recipeRecommendationInput = z.object({
+ query: z.string().trim().max(120).optional().default(""),
+ mealPrepDays: z.number().int().min(0).max(7).optional(),
+ cooksPerDay: z.number().int().min(1).max(4).optional(),
+ limit: z.number().int().min(1).max(50).default(20),
+});
+
+export const clubProfileInput = z.object({
+ state: z.string().trim().min(1).max(80),
+ city: z.string().trim().max(120).optional().nullable(),
+ latitude: z.number().min(-90).max(90).optional().nullable(),
+ longitude: z.number().min(-180).max(180).optional().nullable(),
+ privacyMode: clubPrivacyModeSchema.default("state"),
+});
+
+export const clubActivityInput = z.object({
+ title: z.string().trim().min(1).max(180),
+ category: clubCategorySchema,
+ scope: clubScopeSchema,
+ state: z.string().trim().max(80).optional().nullable(),
+ city: z.string().trim().max(120).optional().nullable(),
+ latitude: z.number().min(-90).max(90).optional().nullable(),
+ longitude: z.number().min(-180).max(180).optional().nullable(),
+ locationLabel: z.string().trim().max(120).optional().nullable(),
+ startsAt: z.string().datetime(),
+ durationMinutes: z.number().int().min(1).max(1440),
+ points: z.number().int().min(0).max(10000),
+ note: z.string().max(2000).optional().nullable(),
+});
+
+// ---------------------------------------------------------------------------
+// Helper utilities (shared between health sub-routers)
+// ---------------------------------------------------------------------------
+
+export function id(prefix: string) {
+ return `${prefix}-${randomUUID()}`;
+}
+
+export function auditHealthMutation(ctx: { user?: { id?: string } | null }, action: string, metadata: Record = {}) {
+ if (process.env.HEALTH_AUDIT_LOGS !== "true") return;
+ console.info("[health]", {
+ action,
+ userId: ctx.user?.id ?? null,
+ ...metadata,
+ at: new Date().toISOString(),
+ });
+}
+
+export async function getPointSettings(db: Database) {
+ const row = await db.query.platformSettings.findFirst({
+ where: (setting, { eq: rowEq }) => rowEq(setting.key, "hellth.points"),
+ });
+ if (!row?.value) return pointSettingDefaults;
+ try {
+ const parsed = JSON.parse(row.value) as Partial;
+ return {
+ recipeSubmissionPoints: Number(parsed.recipeSubmissionPoints) || pointSettingDefaults.recipeSubmissionPoints,
+ clubActivityDefaultPoints: Number(parsed.clubActivityDefaultPoints) || pointSettingDefaults.clubActivityDefaultPoints,
+ };
+ } catch {
+ return pointSettingDefaults;
+ }
+}
+
+export function numberString(value: number | string | null | undefined, fallback = 0) {
+ if (value === null || value === undefined || value === "") return String(fallback);
+ return String(value);
+}
+
+export function startOfWeek(dateKey: string) {
+ const date = new Date(`${dateKey}T12:00:00`);
+ const day = date.getDay() === 0 ? 7 : date.getDay();
+ date.setDate(date.getDate() - day + 1);
+ return date.toISOString().slice(0, 10);
+}
+
+export function addDays(dateKey: string, days: number) {
+ const date = new Date(`${dateKey}T12:00:00`);
+ date.setDate(date.getDate() + days);
+ return date.toISOString().slice(0, 10);
+}
+
+export function dateRange(endDate: string, days: number) {
+ return Array.from({ length: days }, (_, index) => addDays(endDate, index - days + 1));
+}
+
+export function calcRecipeCalories(
+ recipe: { id: string; portions: number },
+ ingredients: Array<{ recipeId: string; foodId: string; amountG: string | number }>,
+ foods: Array<{ id: string; kcalPer100g: string | number }>,
+) {
+ const foodById = new Map(foods.map((food) => [food.id, food]));
+ const total = ingredients
+ .filter((ingredient) => ingredient.recipeId === recipe.id)
+ .reduce((sum, ingredient) => {
+ const food = foodById.get(ingredient.foodId);
+ if (!food) return sum;
+ return sum + (Number(food.kcalPer100g ?? 0) * Number(ingredient.amountG ?? 0)) / 100;
+ }, 0);
+ return Math.round(total / Math.max(1, recipe.portions));
+}
+
+export function normalizeSearch(value: string | null | undefined) {
+ return (value ?? "").toLowerCase();
+}
+
+const meatWords = ["hähnchen", "huhn", "rind", "schwein", "hack", "speck", "salami", "schinken", "wurst", "pute", "truthahn", "fleisch"];
+const fishWords = ["fisch", "lachs", "thunfisch", "forelle", "kabeljau", "garnelen", "shrimps", "sardine"];
+const animalWords = [...meatWords, ...fishWords, "ei", "eier", "milch", "joghurt", "quark", "skyr", "käse", "sahne", "butter", "honig", "molke", "whey", "gelatine"];
+
+export function foodText(food: { name: string; brand?: string | null; category?: string | null }) {
+ return normalizeSearch(`${food.name} ${food.brand ?? ""} ${food.category ?? ""}`);
+}
+
+export function hasAnyWord(text: string, words: string[]) {
+ return words.some((word) => text.includes(word));
+}
+
+export function classifyFoodsForDiet(foods: Array<{ name: string; brand?: string | null; category?: string | null }>) {
+ const texts = foods.map(foodText);
+ const hasMeat = texts.some((text) => hasAnyWord(text, meatWords));
+ const hasFish = texts.some((text) => hasAnyWord(text, fishWords));
+ const hasAnimal = texts.some((text) => hasAnyWord(text, animalWords));
+ return {
+ hasMeat,
+ hasFish,
+ hasAnimal,
+ tags: [
+ !hasAnimal ? "vegan" : undefined,
+ hasAnimal && !hasMeat && !hasFish ? "vegetarisch" : undefined,
+ hasMeat ? "mit Fleisch" : undefined,
+ hasFish ? "mit Fisch" : undefined,
+ ].filter(Boolean) as string[],
+ };
+}
+
+export function recipeNutrition(
+ recipe: { id: string; portions: number },
+ ingredients: Array<{ recipeId: string; foodId: string; amountG: string | number }>,
+ foods: Array<{ id: string; kcalPer100g: string | number; proteinPer100g: string | number; carbsPer100g: string | number; fatPer100g: string | number }>,
+) {
+ const foodById = new Map(foods.map((food) => [food.id, food]));
+ const totals = ingredients
+ .filter((ingredient) => ingredient.recipeId === recipe.id)
+ .reduce((sum, ingredient) => {
+ const food = foodById.get(ingredient.foodId);
+ const factor = Number(ingredient.amountG ?? 0) / 100;
+ if (!food) return sum;
+ return {
+ kcal: sum.kcal + Number(food.kcalPer100g ?? 0) * factor,
+ protein: sum.protein + Number(food.proteinPer100g ?? 0) * factor,
+ carbs: sum.carbs + Number(food.carbsPer100g ?? 0) * factor,
+ fat: sum.fat + Number(food.fatPer100g ?? 0) * factor,
+ };
+ }, { kcal: 0, protein: 0, carbs: 0, fat: 0 });
+ const divisor = Math.max(1, recipe.portions);
+ const perPortion = {
+ kcal: Math.round(totals.kcal / divisor),
+ protein: Math.round((totals.protein / divisor) * 10) / 10,
+ carbs: Math.round((totals.carbs / divisor) * 10) / 10,
+ fat: Math.round((totals.fat / divisor) * 10) / 10,
+ };
+ const macroCalories = {
+ carbs: perPortion.carbs * 4,
+ protein: perPortion.protein * 4,
+ fat: perPortion.fat * 9,
+ };
+ const macroTotal = Math.max(1, macroCalories.carbs + macroCalories.protein + macroCalories.fat);
+ return {
+ ...perPortion,
+ distribution: {
+ carbs: Math.round((macroCalories.carbs / macroTotal) * 100),
+ protein: Math.round((macroCalories.protein / macroTotal) * 100),
+ fat: Math.round((macroCalories.fat / macroTotal) * 100),
+ },
+ };
+}
+
+export function averageNutritionSources(sources: Array<{
+ kcalPer100g: string | number;
+ proteinPer100g: string | number;
+ carbsPer100g: string | number;
+ fatPer100g: string | number;
+}>) {
+ const count = sources.length || 1;
+ return {
+ kcalPer100g: Math.round(sources.reduce((sum, source) => sum + Number(source.kcalPer100g || 0), 0) / count),
+ proteinPer100g: Number((sources.reduce((sum, source) => sum + Number(source.proteinPer100g || 0), 0) / count).toFixed(1)),
+ carbsPer100g: Number((sources.reduce((sum, source) => sum + Number(source.carbsPer100g || 0), 0) / count).toFixed(1)),
+ fatPer100g: Number((sources.reduce((sum, source) => sum + Number(source.fatPer100g || 0), 0) / count).toFixed(1)),
+ };
+}
+
+export function normalizeOpenFoodFactsProduct(product: {
+ code?: string;
+ product_name?: string;
+ brands?: string;
+ categories?: string;
+ serving_quantity?: number | string;
+ nutriments?: Record;
+}) {
+ const nutriments = product.nutriments ?? {};
+ return {
+ provider: "openfoodfacts",
+ providerFoodId: product.code ?? null,
+ name: product.product_name || "Unbenanntes Produkt",
+ brand: product.brands?.split(",")[0]?.trim() || null,
+ category: product.categories?.split(",")[0]?.trim() || "Supermarkt",
+ url: product.code ? `https://world.openfoodfacts.org/product/${product.code}` : "https://world.openfoodfacts.org/",
+ kcalPer100g: Number(nutriments["energy-kcal_100g"] ?? 0),
+ proteinPer100g: Number(nutriments.proteins_100g ?? 0),
+ carbsPer100g: Number(nutriments.carbohydrates_100g ?? 0),
+ fatPer100g: Number(nutriments.fat_100g ?? 0),
+ servingG: Number(product.serving_quantity ?? 100) || 100,
+ confidence: 75,
+ raw: product as Record,
+ };
+}
diff --git a/packages/db/package.json b/packages/db/package.json
index 99090da..7dc4cca 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -13,7 +13,8 @@
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
- "db:studio": "drizzle-kit studio"
+ "db:studio": "drizzle-kit studio",
+ "typecheck": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.44.0",
diff --git a/packages/db/src/schema/community.ts b/packages/db/src/schema/community.ts
new file mode 100644
index 0000000..146f7ea
--- /dev/null
+++ b/packages/db/src/schema/community.ts
@@ -0,0 +1,103 @@
+import {
+ boolean,
+ date,
+ index,
+ integer,
+ jsonb,
+ numeric,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import { wearableProviderEnum } from "./enums";
+
+export const wearableConnections = pgTable(
+ "wearable_connections",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ provider: wearableProviderEnum("provider").notNull(),
+ providerUserId: text("providerUserId"),
+ accessTokenEncrypted: text("accessTokenEncrypted"),
+ refreshTokenEncrypted: text("refreshTokenEncrypted"),
+ scopes: jsonb("scopes").$type().notNull().default([]),
+ expiresAt: timestamp("expiresAt"),
+ lastSyncAt: timestamp("lastSyncAt"),
+ syncEnabled: boolean("syncEnabled").notNull().default(true),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ uniqueProvider: uniqueIndex("wearable_connections_user_provider_unique").on(table.userId, table.provider),
+ }),
+);
+
+export const wearableImports = pgTable(
+ "wearable_imports",
+ {
+ id: text("id").primaryKey(),
+ connectionId: text("connectionId").notNull().references(() => wearableConnections.id, { onDelete: "cascade" }),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ provider: wearableProviderEnum("provider").notNull(),
+ externalId: text("externalId").notNull(),
+ date: date("date").notNull(),
+ payload: jsonb("payload").$type>().notNull().default({}),
+ importedAt: timestamp("importedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ uniqueExternal: uniqueIndex("wearable_imports_provider_external_unique").on(table.provider, table.externalId),
+ userDateIdx: index("wearable_imports_user_date_idx").on(table.userId, table.date),
+ }),
+);
+
+export const clubActivities = pgTable(
+ "club_activities",
+ {
+ id: text("id").primaryKey(),
+ hostUserId: text("hostUserId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ title: text("title").notNull(),
+ category: text("category").notNull().default("other"),
+ scope: text("scope").notNull().default("nationwide"),
+ state: text("state"),
+ city: text("city"),
+ latitude: numeric("latitude", { precision: 9, scale: 6 }),
+ longitude: numeric("longitude", { precision: 9, scale: 6 }),
+ locationLabel: text("locationLabel"),
+ startsAt: timestamp("startsAt").notNull(),
+ durationMinutes: integer("durationMinutes").notNull().default(30),
+ points: integer("points").notNull().default(20),
+ note: text("note"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ startsAtIdx: index("club_activities_starts_at_idx").on(table.startsAt),
+ }),
+);
+
+export const clubProfiles = pgTable("club_profiles", {
+ userId: text("userId").primaryKey().references(() => user.id, { onDelete: "cascade" }),
+ state: text("state").notNull().default("Bayern"),
+ city: text("city"),
+ latitude: numeric("latitude", { precision: 9, scale: 6 }),
+ longitude: numeric("longitude", { precision: 9, scale: 6 }),
+ privacyMode: text("privacyMode").notNull().default("state"),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+});
+
+export const clubActivityParticipants = pgTable(
+ "club_activity_participants",
+ {
+ activityId: text("activityId").notNull().references(() => clubActivities.id, { onDelete: "cascade" }),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ displayName: text("displayName").notNull(),
+ joinedAt: timestamp("joinedAt").notNull().defaultNow(),
+ completedAt: timestamp("completedAt"),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.activityId, table.userId] }),
+ }),
+);
diff --git a/packages/db/src/schema/foods.ts b/packages/db/src/schema/foods.ts
new file mode 100644
index 0000000..35e5708
--- /dev/null
+++ b/packages/db/src/schema/foods.ts
@@ -0,0 +1,104 @@
+import {
+ boolean,
+ index,
+ integer,
+ jsonb,
+ numeric,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import { foodSourceEnum, portionUnitEnum } from "./enums";
+
+export const foods = pgTable(
+ "foods",
+ {
+ id: text("id").primaryKey(),
+ ownerUserId: text("ownerUserId").references(() => user.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ brand: text("brand"),
+ barcode: text("barcode"),
+ category: text("category").notNull().default("Sonstiges"),
+ kcalPer100g: numeric("kcalPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ proteinPer100g: numeric("proteinPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ carbsPer100g: numeric("carbsPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ fatPer100g: numeric("fatPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ servingG: numeric("servingG", { precision: 8, scale: 2 }).notNull().default("100"),
+ preferredUnit: portionUnitEnum("preferredUnit").notNull().default("g"),
+ densityGPerMl: numeric("densityGPerMl", { precision: 8, scale: 4 }),
+ source: foodSourceEnum("source").notNull().default("manual"),
+ isVerified: boolean("isVerified").notNull().default(false),
+ isCommunity: boolean("isCommunity").notNull().default(false),
+ availableEverywhere: boolean("availableEverywhere").notNull().default(true),
+ metadata: jsonb("metadata").$type>().notNull().default({}),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ ownerIdx: index("foods_owner_idx").on(table.ownerUserId),
+ barcodeIdx: index("foods_barcode_idx").on(table.barcode),
+ nameIdx: index("foods_name_idx").on(table.name),
+ }),
+);
+
+export const foodPortions = pgTable(
+ "food_portions",
+ {
+ id: text("id").primaryKey(),
+ foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
+ label: text("label").notNull(),
+ unit: portionUnitEnum("unit").notNull(),
+ amount: numeric("amount", { precision: 8, scale: 2 }).notNull(),
+ gramsEquivalent: numeric("gramsEquivalent", { precision: 8, scale: 2 }).notNull(),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ foodIdx: index("food_portions_food_idx").on(table.foodId),
+ }),
+);
+
+export const foodNutritionSources = pgTable(
+ "food_nutrition_sources",
+ {
+ id: text("id").primaryKey(),
+ foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
+ provider: text("provider").notNull(),
+ providerFoodId: text("providerFoodId"),
+ name: text("name").notNull(),
+ brand: text("brand"),
+ url: text("url"),
+ kcalPer100g: numeric("kcalPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ proteinPer100g: numeric("proteinPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ carbsPer100g: numeric("carbsPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ fatPer100g: numeric("fatPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
+ servingG: numeric("servingG", { precision: 8, scale: 2 }),
+ confidence: integer("confidence").notNull().default(70),
+ raw: jsonb("raw").$type>().notNull().default({}),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ foodIdx: index("food_nutrition_sources_food_idx").on(table.foodId),
+ providerIdx: index("food_nutrition_sources_provider_idx").on(table.provider, table.providerFoodId),
+ }),
+);
+
+export const foodUserPreferences = pgTable(
+ "food_user_preferences",
+ {
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
+ vote: integer("vote").notNull().default(0),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.userId, table.foodId] }),
+ userIdx: index("food_user_preferences_user_idx").on(table.userId),
+ foodIdx: index("food_user_preferences_food_idx").on(table.foodId),
+ }),
+);
+
+export type Food = typeof foods.$inferSelect;
+export type NewFood = typeof foods.$inferInsert;
diff --git a/packages/db/src/schema/health.ts b/packages/db/src/schema/health.ts
deleted file mode 100644
index 725ba95..0000000
--- a/packages/db/src/schema/health.ts
+++ /dev/null
@@ -1,636 +0,0 @@
-import {
- boolean,
- date,
- index,
- integer,
- jsonb,
- numeric,
- pgTable,
- primaryKey,
- text,
- timestamp,
- uniqueIndex,
-} from "drizzle-orm/pg-core";
-import { user } from "./auth";
-import {
- activityLevelEnum,
- activitySourceEnum,
- coachPriorityEnum,
- dietStyleEnum,
- foodSourceEnum,
- healthGoalEnum,
- mealTypeEnum,
- portionUnitEnum,
- reminderKindEnum,
- wearableProviderEnum,
-} from "./enums";
-
-export const healthProfiles = pgTable("health_profiles", {
- userId: text("userId").primaryKey().references(() => user.id, { onDelete: "cascade" }),
- goal: healthGoalEnum("goal").notNull().default("healthier"),
- age: integer("age"),
- heightCm: numeric("heightCm", { precision: 6, scale: 2 }),
- currentWeightKg: numeric("currentWeightKg", { precision: 6, scale: 2 }),
- targetWeightKg: numeric("targetWeightKg", { precision: 6, scale: 2 }),
- activityLevel: activityLevelEnum("activityLevel").notNull().default("moderate"),
- dietStyle: dietStyleEnum("dietStyle").notNull().default("flexible"),
- dietPreferences: jsonb("dietPreferences").$type().notNull().default(["vegetarian", "vegan", "meat", "fish"]),
- mealsPerDay: integer("mealsPerDay").notNull().default(3),
- cooksPerDay: integer("cooksPerDay").notNull().default(1),
- preferredMealPrepDays: integer("preferredMealPrepDays").notNull().default(0),
- meatFishFrequency: text("meatFishFrequency").notNull().default("flexible"),
- cookingTime: text("cookingTime").notNull().default("medium"),
- budgetFocus: boolean("budgetFocus").notNull().default(false),
- excludedFoods: text("excludedFoods"),
- biggestChallenge: text("biggestChallenge"),
- medicalNote: boolean("medicalNote").notNull().default(false),
- completedAt: timestamp("completedAt"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
-});
-
-export const healthGoals = pgTable("health_goals", {
- userId: text("userId").primaryKey().references(() => user.id, { onDelete: "cascade" }),
- drinkMl: integer("drinkMl").notNull().default(2500),
- calories: integer("calories").notNull().default(2000),
- carbsG: integer("carbsG").notNull().default(200),
- fatG: integer("fatG").notNull().default(67),
- proteinG: integer("proteinG").notNull().default(150),
- weightKg: numeric("weightKg", { precision: 6, scale: 2 }),
- fastingHours: integer("fastingHours").notNull().default(12),
- weeklyCalories: integer("weeklyCalories"),
- sportAddsCalories: boolean("sportAddsCalories").notNull().default(true),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
-});
-
-export const foods = pgTable(
- "foods",
- {
- id: text("id").primaryKey(),
- ownerUserId: text("ownerUserId").references(() => user.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- brand: text("brand"),
- barcode: text("barcode"),
- category: text("category").notNull().default("Sonstiges"),
- kcalPer100g: numeric("kcalPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- proteinPer100g: numeric("proteinPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- carbsPer100g: numeric("carbsPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- fatPer100g: numeric("fatPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- servingG: numeric("servingG", { precision: 8, scale: 2 }).notNull().default("100"),
- preferredUnit: portionUnitEnum("preferredUnit").notNull().default("g"),
- densityGPerMl: numeric("densityGPerMl", { precision: 8, scale: 4 }),
- source: foodSourceEnum("source").notNull().default("manual"),
- isVerified: boolean("isVerified").notNull().default(false),
- isCommunity: boolean("isCommunity").notNull().default(false),
- availableEverywhere: boolean("availableEverywhere").notNull().default(true),
- metadata: jsonb("metadata").$type>().notNull().default({}),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- ownerIdx: index("foods_owner_idx").on(table.ownerUserId),
- barcodeIdx: index("foods_barcode_idx").on(table.barcode),
- nameIdx: index("foods_name_idx").on(table.name),
- }),
-);
-
-export const foodPortions = pgTable(
- "food_portions",
- {
- id: text("id").primaryKey(),
- foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
- label: text("label").notNull(),
- unit: portionUnitEnum("unit").notNull(),
- amount: numeric("amount", { precision: 8, scale: 2 }).notNull(),
- gramsEquivalent: numeric("gramsEquivalent", { precision: 8, scale: 2 }).notNull(),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- },
- (table) => ({
- foodIdx: index("food_portions_food_idx").on(table.foodId),
- }),
-);
-
-export const foodNutritionSources = pgTable(
- "food_nutrition_sources",
- {
- id: text("id").primaryKey(),
- foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
- provider: text("provider").notNull(),
- providerFoodId: text("providerFoodId"),
- name: text("name").notNull(),
- brand: text("brand"),
- url: text("url"),
- kcalPer100g: numeric("kcalPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- proteinPer100g: numeric("proteinPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- carbsPer100g: numeric("carbsPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- fatPer100g: numeric("fatPer100g", { precision: 8, scale: 2 }).notNull().default("0"),
- servingG: numeric("servingG", { precision: 8, scale: 2 }),
- confidence: integer("confidence").notNull().default(70),
- raw: jsonb("raw").$type>().notNull().default({}),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- foodIdx: index("food_nutrition_sources_food_idx").on(table.foodId),
- providerIdx: index("food_nutrition_sources_provider_idx").on(table.provider, table.providerFoodId),
- }),
-);
-
-export const foodUserPreferences = pgTable(
- "food_user_preferences",
- {
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
- vote: integer("vote").notNull().default(0),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- pk: primaryKey({ columns: [table.userId, table.foodId] }),
- userIdx: index("food_user_preferences_user_idx").on(table.userId),
- foodIdx: index("food_user_preferences_food_idx").on(table.foodId),
- }),
-);
-
-export const stores = pgTable("stores", {
- id: text("id").primaryKey(),
- ownerUserId: text("ownerUserId").references(() => user.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- address: text("address"),
- placeId: text("placeId"),
- latitude: numeric("latitude", { precision: 9, scale: 6 }),
- longitude: numeric("longitude", { precision: 9, scale: 6 }),
- favorite: boolean("favorite").notNull().default(false),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
-});
-
-export const storeChangeRequests = pgTable(
- "store_change_requests",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- storeId: text("storeId").references(() => stores.id, { onDelete: "set null" }),
- requestType: text("requestType").notNull().default("structure_update"),
- status: text("status").notNull().default("pending"),
- title: text("title").notNull(),
- note: text("note"),
- proposedStore: jsonb("proposedStore").$type>().notNull().default({}),
- proposedDepartments: jsonb("proposedDepartments").$type>>().notNull().default([]),
- reviewedBy: text("reviewedBy").references(() => user.id, { onDelete: "set null" }),
- reviewedAt: timestamp("reviewedAt"),
- reviewNote: text("reviewNote"),
- acceptedStoreId: text("acceptedStoreId").references(() => stores.id, { onDelete: "set null" }),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- userIdx: index("store_change_requests_user_idx").on(table.userId),
- statusIdx: index("store_change_requests_status_idx").on(table.status, table.createdAt),
- }),
-);
-
-export const storeUserPreferences = pgTable(
- "store_user_preferences",
- {
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- storeId: text("storeId").notNull().references(() => stores.id, { onDelete: "cascade" }),
- favorite: boolean("favorite").notNull().default(false),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- pk: primaryKey({ columns: [table.userId, table.storeId] }),
- userIdx: index("store_user_preferences_user_idx").on(table.userId),
- }),
-);
-
-export const storeDepartments = pgTable(
- "store_departments",
- {
- id: text("id").primaryKey(),
- storeId: text("storeId").notNull().references(() => stores.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- sortOrder: integer("sortOrder").notNull().default(0),
- },
- (table) => ({
- storeIdx: index("store_departments_store_idx").on(table.storeId),
- }),
-);
-
-export const foodStores = pgTable(
- "food_stores",
- {
- foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
- storeId: text("storeId").notNull().references(() => stores.id, { onDelete: "cascade" }),
- departmentId: text("departmentId").references(() => storeDepartments.id, { onDelete: "set null" }),
- lastPrice: numeric("lastPrice", { precision: 8, scale: 2 }),
- availabilityTags: jsonb("availabilityTags").$type().notNull().default([]),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- pk: primaryKey({ columns: [table.foodId, table.storeId] }),
- storeIdx: index("food_stores_store_idx").on(table.storeId),
- }),
-);
-
-export const recipes = pgTable(
- "recipes",
- {
- id: text("id").primaryKey(),
- ownerUserId: text("ownerUserId").notNull().references(() => user.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- portions: integer("portions").notNull().default(1),
- minutes: integer("minutes").notNull().default(0),
- notes: text("notes"),
- mealPrepDays: integer("mealPrepDays").notNull().default(0),
- isPublic: boolean("isPublic").notNull().default(false),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- ownerIdx: index("recipes_owner_idx").on(table.ownerUserId),
- }),
-);
-
-export const recipeIngredients = pgTable(
- "recipe_ingredients",
- {
- id: text("id").primaryKey(),
- recipeId: text("recipeId").notNull().references(() => recipes.id, { onDelete: "cascade" }),
- foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "restrict" }),
- amountG: numeric("amountG", { precision: 8, scale: 2 }).notNull(),
- displayAmount: numeric("displayAmount", { precision: 8, scale: 2 }),
- displayUnit: portionUnitEnum("displayUnit"),
- portionId: text("portionId").references(() => foodPortions.id, { onDelete: "set null" }),
- sortOrder: integer("sortOrder").notNull().default(0),
- },
- (table) => ({
- recipeIdx: index("recipe_ingredients_recipe_idx").on(table.recipeId),
- }),
-);
-
-export const recipeSubmissions = pgTable(
- "recipe_submissions",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- portions: integer("portions").notNull().default(1),
- minutes: integer("minutes").notNull().default(0),
- notes: text("notes"),
- mealPrepDays: integer("mealPrepDays").notNull().default(0),
- status: text("status").notNull().default("pending"),
- reviewNote: text("reviewNote"),
- reviewedBy: text("reviewedBy").references(() => user.id, { onDelete: "set null" }),
- reviewedAt: timestamp("reviewedAt"),
- acceptedRecipeId: text("acceptedRecipeId").references(() => recipes.id, { onDelete: "set null" }),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- userIdx: index("recipe_submissions_user_idx").on(table.userId),
- statusIdx: index("recipe_submissions_status_idx").on(table.status, table.createdAt),
- }),
-);
-
-export const recipeSubmissionIngredients = pgTable(
- "recipe_submission_ingredients",
- {
- id: text("id").primaryKey(),
- submissionId: text("submissionId").notNull().references(() => recipeSubmissions.id, { onDelete: "cascade" }),
- foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "restrict" }),
- amountG: numeric("amountG", { precision: 8, scale: 2 }).notNull(),
- displayAmount: numeric("displayAmount", { precision: 8, scale: 2 }),
- displayUnit: portionUnitEnum("displayUnit"),
- portionId: text("portionId").references(() => foodPortions.id, { onDelete: "set null" }),
- sortOrder: integer("sortOrder").notNull().default(0),
- },
- (table) => ({
- submissionIdx: index("recipe_submission_ingredients_submission_idx").on(table.submissionId),
- }),
-);
-
-export const recipeRatings = pgTable(
- "recipe_ratings",
- {
- id: text("id").primaryKey(),
- recipeId: text("recipeId").notNull().references(() => recipes.id, { onDelete: "cascade" }),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- rating: integer("rating").notNull(),
- comment: text("comment"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- uniqueUserRecipe: uniqueIndex("recipe_ratings_user_recipe_unique").on(table.recipeId, table.userId),
- }),
-);
-
-export const mealPlans = pgTable(
- "meal_plans",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- date: date("date").notNull(),
- mealType: mealTypeEnum("mealType").notNull(),
- recipeId: text("recipeId").references(() => recipes.id, { onDelete: "set null" }),
- name: text("name").notNull(),
- portions: numeric("portions", { precision: 8, scale: 2 }).notNull().default("1"),
- plannedCalories: integer("plannedCalories").notNull().default(0),
- note: text("note"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- userDateIdx: index("meal_plans_user_date_idx").on(table.userId, table.date),
- }),
-);
-
-export const mealLogs = pgTable(
- "meal_logs",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- date: date("date").notNull(),
- mealType: mealTypeEnum("mealType").notNull(),
- foodId: text("foodId").references(() => foods.id, { onDelete: "set null" }),
- recipeId: text("recipeId").references(() => recipes.id, { onDelete: "set null" }),
- name: text("name").notNull(),
- amountLabel: text("amountLabel"),
- amountG: numeric("amountG", { precision: 8, scale: 2 }),
- calories: integer("calories").notNull().default(0),
- proteinG: numeric("proteinG", { precision: 8, scale: 2 }).notNull().default("0"),
- carbsG: numeric("carbsG", { precision: 8, scale: 2 }).notNull().default("0"),
- fatG: numeric("fatG", { precision: 8, scale: 2 }).notNull().default("0"),
- source: text("source").notNull().default("manual"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- userDateIdx: index("meal_logs_user_date_idx").on(table.userId, table.date),
- }),
-);
-
-export const drinkLogs = pgTable(
- "drink_logs",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- date: date("date").notNull(),
- amountMl: integer("amountMl").notNull(),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- },
- (table) => ({
- userDateIdx: index("drink_logs_user_date_idx").on(table.userId, table.date),
- }),
-);
-
-export const weightLogs = pgTable(
- "weight_logs",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- date: date("date").notNull(),
- weightKg: numeric("weightKg", { precision: 6, scale: 2 }).notNull(),
- note: text("note"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- },
- (table) => ({
- uniqueUserDate: uniqueIndex("weight_logs_user_date_unique").on(table.userId, table.date),
- }),
-);
-
-export const weeklyCaloriePlans = pgTable(
- "weekly_calorie_plans",
- {
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- date: date("date").notNull(),
- plannedCalories: integer("plannedCalories").notNull(),
- note: text("note"),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- pk: primaryKey({ columns: [table.userId, table.date] }),
- }),
-);
-
-export const activityTemplates = pgTable(
- "activity_templates",
- {
- id: text("id").primaryKey(),
- ownerUserId: text("ownerUserId").references(() => user.id, { onDelete: "cascade" }),
- name: text("name").notNull(),
- category: text("category").notNull().default("habit"),
- defaultMinutes: integer("defaultMinutes").notNull().default(10),
- defaultPoints: integer("defaultPoints").notNull().default(10),
- defaultCalories: integer("defaultCalories").notNull().default(0),
- icon: text("icon").notNull().default("custom"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- },
- (table) => ({
- ownerIdx: index("activity_templates_owner_idx").on(table.ownerUserId),
- }),
-);
-
-export const activityLogs = pgTable(
- "activity_logs",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- templateId: text("templateId").references(() => activityTemplates.id, { onDelete: "set null" }),
- date: date("date").notNull(),
- name: text("name").notNull(),
- minutes: integer("minutes").notNull().default(0),
- points: integer("points").notNull().default(0),
- calories: integer("calories").notNull().default(0),
- source: activitySourceEnum("source").notNull().default("manual"),
- externalId: text("externalId"),
- note: text("note"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- },
- (table) => ({
- userDateIdx: index("activity_logs_user_date_idx").on(table.userId, table.date),
- externalUnique: uniqueIndex("activity_logs_source_external_unique").on(table.userId, table.source, table.externalId),
- }),
-);
-
-export const shoppingLists = pgTable(
- "shopping_lists",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- storeId: text("storeId").references(() => stores.id, { onDelete: "set null" }),
- name: text("name").notNull(),
- startsOn: date("startsOn"),
- endsOn: date("endsOn"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- userIdx: index("shopping_lists_user_idx").on(table.userId),
- }),
-);
-
-export const shoppingListItems = pgTable(
- "shopping_list_items",
- {
- id: text("id").primaryKey(),
- shoppingListId: text("shoppingListId").notNull().references(() => shoppingLists.id, { onDelete: "cascade" }),
- foodId: text("foodId").references(() => foods.id, { onDelete: "set null" }),
- name: text("name").notNull(),
- category: text("category").notNull().default("Sonstiges"),
- amountG: numeric("amountG", { precision: 8, scale: 2 }).notNull().default("0"),
- purchaseAmount: text("purchaseAmount"),
- checked: boolean("checked").notNull().default(false),
- sortOrder: integer("sortOrder").notNull().default(0),
- },
- (table) => ({
- listIdx: index("shopping_list_items_list_idx").on(table.shoppingListId),
- }),
-);
-
-export const wearableConnections = pgTable(
- "wearable_connections",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- provider: wearableProviderEnum("provider").notNull(),
- providerUserId: text("providerUserId"),
- accessTokenEncrypted: text("accessTokenEncrypted"),
- refreshTokenEncrypted: text("refreshTokenEncrypted"),
- scopes: jsonb("scopes").$type().notNull().default([]),
- expiresAt: timestamp("expiresAt"),
- lastSyncAt: timestamp("lastSyncAt"),
- syncEnabled: boolean("syncEnabled").notNull().default(true),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- uniqueProvider: uniqueIndex("wearable_connections_user_provider_unique").on(table.userId, table.provider),
- }),
-);
-
-export const wearableImports = pgTable(
- "wearable_imports",
- {
- id: text("id").primaryKey(),
- connectionId: text("connectionId").notNull().references(() => wearableConnections.id, { onDelete: "cascade" }),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- provider: wearableProviderEnum("provider").notNull(),
- externalId: text("externalId").notNull(),
- date: date("date").notNull(),
- payload: jsonb("payload").$type>().notNull().default({}),
- importedAt: timestamp("importedAt").notNull().defaultNow(),
- },
- (table) => ({
- uniqueExternal: uniqueIndex("wearable_imports_provider_external_unique").on(table.provider, table.externalId),
- userDateIdx: index("wearable_imports_user_date_idx").on(table.userId, table.date),
- }),
-);
-
-export const coachPrinciples = pgTable("coach_principles", {
- id: text("id").primaryKey(),
- title: text("title").notNull(),
- body: text("body").notNull(),
- category: text("category").notNull().default("general"),
- isActive: boolean("isActive").notNull().default(true),
- sortOrder: integer("sortOrder").notNull().default(0),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
-});
-
-export const coachRecommendations = pgTable(
- "coach_recommendations",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- title: text("title").notNull(),
- body: text("body").notNull(),
- priority: coachPriorityEnum("priority").notNull().default("medium"),
- category: text("category").notNull().default("daily"),
- source: text("source").notNull().default("rule"),
- validFrom: date("validFrom"),
- validTo: date("validTo"),
- dismissedAt: timestamp("dismissedAt"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- },
- (table) => ({
- userIdx: index("coach_recommendations_user_idx").on(table.userId),
- }),
-);
-
-export const reminderRules = pgTable(
- "reminder_rules",
- {
- id: text("id").primaryKey(),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- kind: reminderKindEnum("kind").notNull(),
- enabled: boolean("enabled").notNull().default(true),
- intervalMinutes: integer("intervalMinutes").notNull().default(90),
- startTime: text("startTime").notNull().default("08:00"),
- endTime: text("endTime").notNull().default("20:00"),
- message: text("message").notNull(),
- quietDays: jsonb("quietDays").$type().notNull().default([]),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- uniqueKind: uniqueIndex("reminder_rules_user_kind_unique").on(table.userId, table.kind),
- }),
-);
-
-export const clubActivities = pgTable(
- "club_activities",
- {
- id: text("id").primaryKey(),
- hostUserId: text("hostUserId").notNull().references(() => user.id, { onDelete: "cascade" }),
- title: text("title").notNull(),
- category: text("category").notNull().default("other"),
- scope: text("scope").notNull().default("nationwide"),
- state: text("state"),
- city: text("city"),
- latitude: numeric("latitude", { precision: 9, scale: 6 }),
- longitude: numeric("longitude", { precision: 9, scale: 6 }),
- locationLabel: text("locationLabel"),
- startsAt: timestamp("startsAt").notNull(),
- durationMinutes: integer("durationMinutes").notNull().default(30),
- points: integer("points").notNull().default(20),
- note: text("note"),
- createdAt: timestamp("createdAt").notNull().defaultNow(),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
- },
- (table) => ({
- startsAtIdx: index("club_activities_starts_at_idx").on(table.startsAt),
- }),
-);
-
-export const clubProfiles = pgTable("club_profiles", {
- userId: text("userId").primaryKey().references(() => user.id, { onDelete: "cascade" }),
- state: text("state").notNull().default("Bayern"),
- city: text("city"),
- latitude: numeric("latitude", { precision: 9, scale: 6 }),
- longitude: numeric("longitude", { precision: 9, scale: 6 }),
- privacyMode: text("privacyMode").notNull().default("state"),
- updatedAt: timestamp("updatedAt").notNull().defaultNow(),
-});
-
-export const clubActivityParticipants = pgTable(
- "club_activity_participants",
- {
- activityId: text("activityId").notNull().references(() => clubActivities.id, { onDelete: "cascade" }),
- userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
- displayName: text("displayName").notNull(),
- joinedAt: timestamp("joinedAt").notNull().defaultNow(),
- completedAt: timestamp("completedAt"),
- },
- (table) => ({
- pk: primaryKey({ columns: [table.activityId, table.userId] }),
- }),
-);
-
-export type HealthProfile = typeof healthProfiles.$inferSelect;
-export type NewHealthProfile = typeof healthProfiles.$inferInsert;
-export type HealthGoal = typeof healthGoals.$inferSelect;
-export type NewHealthGoal = typeof healthGoals.$inferInsert;
-export type Food = typeof foods.$inferSelect;
-export type NewFood = typeof foods.$inferInsert;
-export type Recipe = typeof recipes.$inferSelect;
-export type NewRecipe = typeof recipes.$inferInsert;
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index de3e009..c7de50d 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -1,4 +1,10 @@
export * from "./auth";
export * from "./enums";
export * from "./platform-settings";
-export * from "./health";
+export * from "./profiles";
+export * from "./foods";
+export * from "./stores";
+export * from "./recipes";
+export * from "./tracking";
+export * from "./shopping";
+export * from "./community";
diff --git a/packages/db/src/schema/profiles.ts b/packages/db/src/schema/profiles.ts
new file mode 100644
index 0000000..2c8a16f
--- /dev/null
+++ b/packages/db/src/schema/profiles.ts
@@ -0,0 +1,128 @@
+import {
+ boolean,
+ date,
+ index,
+ integer,
+ jsonb,
+ numeric,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import {
+ activityLevelEnum,
+ coachPriorityEnum,
+ dietStyleEnum,
+ healthGoalEnum,
+ reminderKindEnum,
+} from "./enums";
+
+export const healthProfiles = pgTable("health_profiles", {
+ userId: text("userId").primaryKey().references(() => user.id, { onDelete: "cascade" }),
+ goal: healthGoalEnum("goal").notNull().default("healthier"),
+ age: integer("age"),
+ heightCm: numeric("heightCm", { precision: 6, scale: 2 }),
+ currentWeightKg: numeric("currentWeightKg", { precision: 6, scale: 2 }),
+ targetWeightKg: numeric("targetWeightKg", { precision: 6, scale: 2 }),
+ activityLevel: activityLevelEnum("activityLevel").notNull().default("moderate"),
+ dietStyle: dietStyleEnum("dietStyle").notNull().default("flexible"),
+ dietPreferences: jsonb("dietPreferences").$type().notNull().default(["vegetarian", "vegan", "meat", "fish"]),
+ mealsPerDay: integer("mealsPerDay").notNull().default(3),
+ cooksPerDay: integer("cooksPerDay").notNull().default(1),
+ preferredMealPrepDays: integer("preferredMealPrepDays").notNull().default(0),
+ meatFishFrequency: text("meatFishFrequency").notNull().default("flexible"),
+ cookingTime: text("cookingTime").notNull().default("medium"),
+ budgetFocus: boolean("budgetFocus").notNull().default(false),
+ excludedFoods: text("excludedFoods"),
+ biggestChallenge: text("biggestChallenge"),
+ medicalNote: boolean("medicalNote").notNull().default(false),
+ completedAt: timestamp("completedAt"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+});
+
+export const healthGoals = pgTable("health_goals", {
+ userId: text("userId").primaryKey().references(() => user.id, { onDelete: "cascade" }),
+ drinkMl: integer("drinkMl").notNull().default(2500),
+ calories: integer("calories").notNull().default(2000),
+ carbsG: integer("carbsG").notNull().default(200),
+ fatG: integer("fatG").notNull().default(67),
+ proteinG: integer("proteinG").notNull().default(150),
+ weightKg: numeric("weightKg", { precision: 6, scale: 2 }),
+ fastingHours: integer("fastingHours").notNull().default(12),
+ weeklyCalories: integer("weeklyCalories"),
+ sportAddsCalories: boolean("sportAddsCalories").notNull().default(true),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+});
+
+export const weeklyCaloriePlans = pgTable(
+ "weekly_calorie_plans",
+ {
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ date: date("date").notNull(),
+ plannedCalories: integer("plannedCalories").notNull(),
+ note: text("note"),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.userId, table.date] }),
+ }),
+);
+
+export const reminderRules = pgTable(
+ "reminder_rules",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ kind: reminderKindEnum("kind").notNull(),
+ enabled: boolean("enabled").notNull().default(true),
+ intervalMinutes: integer("intervalMinutes").notNull().default(90),
+ startTime: text("startTime").notNull().default("08:00"),
+ endTime: text("endTime").notNull().default("20:00"),
+ message: text("message").notNull(),
+ quietDays: jsonb("quietDays").$type().notNull().default([]),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ uniqueKind: uniqueIndex("reminder_rules_user_kind_unique").on(table.userId, table.kind),
+ }),
+);
+
+export const coachPrinciples = pgTable("coach_principles", {
+ id: text("id").primaryKey(),
+ title: text("title").notNull(),
+ body: text("body").notNull(),
+ category: text("category").notNull().default("general"),
+ isActive: boolean("isActive").notNull().default(true),
+ sortOrder: integer("sortOrder").notNull().default(0),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+});
+
+export const coachRecommendations = pgTable(
+ "coach_recommendations",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ title: text("title").notNull(),
+ body: text("body").notNull(),
+ priority: coachPriorityEnum("priority").notNull().default("medium"),
+ category: text("category").notNull().default("daily"),
+ source: text("source").notNull().default("rule"),
+ validFrom: date("validFrom"),
+ validTo: date("validTo"),
+ dismissedAt: timestamp("dismissedAt"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userIdx: index("coach_recommendations_user_idx").on(table.userId),
+ }),
+);
+
+export type HealthProfile = typeof healthProfiles.$inferSelect;
+export type NewHealthProfile = typeof healthProfiles.$inferInsert;
+export type HealthGoal = typeof healthGoals.$inferSelect;
+export type NewHealthGoal = typeof healthGoals.$inferInsert;
diff --git a/packages/db/src/schema/recipes.ts b/packages/db/src/schema/recipes.ts
new file mode 100644
index 0000000..cb3ce48
--- /dev/null
+++ b/packages/db/src/schema/recipes.ts
@@ -0,0 +1,109 @@
+import {
+ boolean,
+ index,
+ integer,
+ numeric,
+ pgTable,
+ text,
+ timestamp,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import { foods, foodPortions } from "./foods";
+import { portionUnitEnum } from "./enums";
+
+export const recipes = pgTable(
+ "recipes",
+ {
+ id: text("id").primaryKey(),
+ ownerUserId: text("ownerUserId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ portions: integer("portions").notNull().default(1),
+ minutes: integer("minutes").notNull().default(0),
+ notes: text("notes"),
+ mealPrepDays: integer("mealPrepDays").notNull().default(0),
+ isPublic: boolean("isPublic").notNull().default(false),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ ownerIdx: index("recipes_owner_idx").on(table.ownerUserId),
+ }),
+);
+
+export const recipeIngredients = pgTable(
+ "recipe_ingredients",
+ {
+ id: text("id").primaryKey(),
+ recipeId: text("recipeId").notNull().references(() => recipes.id, { onDelete: "cascade" }),
+ foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "restrict" }),
+ amountG: numeric("amountG", { precision: 8, scale: 2 }).notNull(),
+ displayAmount: numeric("displayAmount", { precision: 8, scale: 2 }),
+ displayUnit: portionUnitEnum("displayUnit"),
+ portionId: text("portionId").references(() => foodPortions.id, { onDelete: "set null" }),
+ sortOrder: integer("sortOrder").notNull().default(0),
+ },
+ (table) => ({
+ recipeIdx: index("recipe_ingredients_recipe_idx").on(table.recipeId),
+ }),
+);
+
+export const recipeSubmissions = pgTable(
+ "recipe_submissions",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ portions: integer("portions").notNull().default(1),
+ minutes: integer("minutes").notNull().default(0),
+ notes: text("notes"),
+ mealPrepDays: integer("mealPrepDays").notNull().default(0),
+ status: text("status").notNull().default("pending"),
+ reviewNote: text("reviewNote"),
+ reviewedBy: text("reviewedBy").references(() => user.id, { onDelete: "set null" }),
+ reviewedAt: timestamp("reviewedAt"),
+ acceptedRecipeId: text("acceptedRecipeId").references(() => recipes.id, { onDelete: "set null" }),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userIdx: index("recipe_submissions_user_idx").on(table.userId),
+ statusIdx: index("recipe_submissions_status_idx").on(table.status, table.createdAt),
+ }),
+);
+
+export const recipeSubmissionIngredients = pgTable(
+ "recipe_submission_ingredients",
+ {
+ id: text("id").primaryKey(),
+ submissionId: text("submissionId").notNull().references(() => recipeSubmissions.id, { onDelete: "cascade" }),
+ foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "restrict" }),
+ amountG: numeric("amountG", { precision: 8, scale: 2 }).notNull(),
+ displayAmount: numeric("displayAmount", { precision: 8, scale: 2 }),
+ displayUnit: portionUnitEnum("displayUnit"),
+ portionId: text("portionId").references(() => foodPortions.id, { onDelete: "set null" }),
+ sortOrder: integer("sortOrder").notNull().default(0),
+ },
+ (table) => ({
+ submissionIdx: index("recipe_submission_ingredients_submission_idx").on(table.submissionId),
+ }),
+);
+
+export const recipeRatings = pgTable(
+ "recipe_ratings",
+ {
+ id: text("id").primaryKey(),
+ recipeId: text("recipeId").notNull().references(() => recipes.id, { onDelete: "cascade" }),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ rating: integer("rating").notNull(),
+ comment: text("comment"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ uniqueUserRecipe: uniqueIndex("recipe_ratings_user_recipe_unique").on(table.recipeId, table.userId),
+ }),
+);
+
+export type Recipe = typeof recipes.$inferSelect;
+export type NewRecipe = typeof recipes.$inferInsert;
diff --git a/packages/db/src/schema/shopping.ts b/packages/db/src/schema/shopping.ts
new file mode 100644
index 0000000..ce3b4c0
--- /dev/null
+++ b/packages/db/src/schema/shopping.ts
@@ -0,0 +1,48 @@
+import {
+ boolean,
+ date,
+ index,
+ integer,
+ numeric,
+ pgTable,
+ text,
+ timestamp,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import { foods } from "./foods";
+import { stores } from "./stores";
+
+export const shoppingLists = pgTable(
+ "shopping_lists",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ storeId: text("storeId").references(() => stores.id, { onDelete: "set null" }),
+ name: text("name").notNull(),
+ startsOn: date("startsOn"),
+ endsOn: date("endsOn"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userIdx: index("shopping_lists_user_idx").on(table.userId),
+ }),
+);
+
+export const shoppingListItems = pgTable(
+ "shopping_list_items",
+ {
+ id: text("id").primaryKey(),
+ shoppingListId: text("shoppingListId").notNull().references(() => shoppingLists.id, { onDelete: "cascade" }),
+ foodId: text("foodId").references(() => foods.id, { onDelete: "set null" }),
+ name: text("name").notNull(),
+ category: text("category").notNull().default("Sonstiges"),
+ amountG: numeric("amountG", { precision: 8, scale: 2 }).notNull().default("0"),
+ purchaseAmount: text("purchaseAmount"),
+ checked: boolean("checked").notNull().default(false),
+ sortOrder: integer("sortOrder").notNull().default(0),
+ },
+ (table) => ({
+ listIdx: index("shopping_list_items_list_idx").on(table.shoppingListId),
+ }),
+);
diff --git a/packages/db/src/schema/stores.ts b/packages/db/src/schema/stores.ts
new file mode 100644
index 0000000..de2f663
--- /dev/null
+++ b/packages/db/src/schema/stores.ts
@@ -0,0 +1,94 @@
+import {
+ boolean,
+ index,
+ integer,
+ jsonb,
+ numeric,
+ pgTable,
+ primaryKey,
+ text,
+ timestamp,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import { foods } from "./foods";
+
+export const stores = pgTable("stores", {
+ id: text("id").primaryKey(),
+ ownerUserId: text("ownerUserId").references(() => user.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ address: text("address"),
+ placeId: text("placeId"),
+ latitude: numeric("latitude", { precision: 9, scale: 6 }),
+ longitude: numeric("longitude", { precision: 9, scale: 6 }),
+ favorite: boolean("favorite").notNull().default(false),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+});
+
+export const storeChangeRequests = pgTable(
+ "store_change_requests",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ storeId: text("storeId").references(() => stores.id, { onDelete: "set null" }),
+ requestType: text("requestType").notNull().default("structure_update"),
+ status: text("status").notNull().default("pending"),
+ title: text("title").notNull(),
+ note: text("note"),
+ proposedStore: jsonb("proposedStore").$type>().notNull().default({}),
+ proposedDepartments: jsonb("proposedDepartments").$type>>().notNull().default([]),
+ reviewedBy: text("reviewedBy").references(() => user.id, { onDelete: "set null" }),
+ reviewedAt: timestamp("reviewedAt"),
+ reviewNote: text("reviewNote"),
+ acceptedStoreId: text("acceptedStoreId").references(() => stores.id, { onDelete: "set null" }),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userIdx: index("store_change_requests_user_idx").on(table.userId),
+ statusIdx: index("store_change_requests_status_idx").on(table.status, table.createdAt),
+ }),
+);
+
+export const storeUserPreferences = pgTable(
+ "store_user_preferences",
+ {
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ storeId: text("storeId").notNull().references(() => stores.id, { onDelete: "cascade" }),
+ favorite: boolean("favorite").notNull().default(false),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.userId, table.storeId] }),
+ userIdx: index("store_user_preferences_user_idx").on(table.userId),
+ }),
+);
+
+export const storeDepartments = pgTable(
+ "store_departments",
+ {
+ id: text("id").primaryKey(),
+ storeId: text("storeId").notNull().references(() => stores.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ sortOrder: integer("sortOrder").notNull().default(0),
+ },
+ (table) => ({
+ storeIdx: index("store_departments_store_idx").on(table.storeId),
+ }),
+);
+
+export const foodStores = pgTable(
+ "food_stores",
+ {
+ foodId: text("foodId").notNull().references(() => foods.id, { onDelete: "cascade" }),
+ storeId: text("storeId").notNull().references(() => stores.id, { onDelete: "cascade" }),
+ departmentId: text("departmentId").references(() => storeDepartments.id, { onDelete: "set null" }),
+ lastPrice: numeric("lastPrice", { precision: 8, scale: 2 }),
+ availabilityTags: jsonb("availabilityTags").$type().notNull().default([]),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ pk: primaryKey({ columns: [table.foodId, table.storeId] }),
+ storeIdx: index("food_stores_store_idx").on(table.storeId),
+ }),
+);
diff --git a/packages/db/src/schema/tracking.ts b/packages/db/src/schema/tracking.ts
new file mode 100644
index 0000000..961616d
--- /dev/null
+++ b/packages/db/src/schema/tracking.ts
@@ -0,0 +1,128 @@
+import {
+ date,
+ index,
+ integer,
+ numeric,
+ pgTable,
+ text,
+ timestamp,
+ uniqueIndex,
+} from "drizzle-orm/pg-core";
+import { user } from "./auth";
+import { foods } from "./foods";
+import { recipes } from "./recipes";
+import { activitySourceEnum, mealTypeEnum } from "./enums";
+
+export const mealPlans = pgTable(
+ "meal_plans",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ date: date("date").notNull(),
+ mealType: mealTypeEnum("mealType").notNull(),
+ recipeId: text("recipeId").references(() => recipes.id, { onDelete: "set null" }),
+ name: text("name").notNull(),
+ portions: numeric("portions", { precision: 8, scale: 2 }).notNull().default("1"),
+ plannedCalories: integer("plannedCalories").notNull().default(0),
+ note: text("note"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userDateIdx: index("meal_plans_user_date_idx").on(table.userId, table.date),
+ }),
+);
+
+export const mealLogs = pgTable(
+ "meal_logs",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ date: date("date").notNull(),
+ mealType: mealTypeEnum("mealType").notNull(),
+ foodId: text("foodId").references(() => foods.id, { onDelete: "set null" }),
+ recipeId: text("recipeId").references(() => recipes.id, { onDelete: "set null" }),
+ name: text("name").notNull(),
+ amountLabel: text("amountLabel"),
+ amountG: numeric("amountG", { precision: 8, scale: 2 }),
+ calories: integer("calories").notNull().default(0),
+ proteinG: numeric("proteinG", { precision: 8, scale: 2 }).notNull().default("0"),
+ carbsG: numeric("carbsG", { precision: 8, scale: 2 }).notNull().default("0"),
+ fatG: numeric("fatG", { precision: 8, scale: 2 }).notNull().default("0"),
+ source: text("source").notNull().default("manual"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ updatedAt: timestamp("updatedAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userDateIdx: index("meal_logs_user_date_idx").on(table.userId, table.date),
+ }),
+);
+
+export const drinkLogs = pgTable(
+ "drink_logs",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ date: date("date").notNull(),
+ amountMl: integer("amountMl").notNull(),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userDateIdx: index("drink_logs_user_date_idx").on(table.userId, table.date),
+ }),
+);
+
+export const weightLogs = pgTable(
+ "weight_logs",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ date: date("date").notNull(),
+ weightKg: numeric("weightKg", { precision: 6, scale: 2 }).notNull(),
+ note: text("note"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ uniqueUserDate: uniqueIndex("weight_logs_user_date_unique").on(table.userId, table.date),
+ }),
+);
+
+export const activityTemplates = pgTable(
+ "activity_templates",
+ {
+ id: text("id").primaryKey(),
+ ownerUserId: text("ownerUserId").references(() => user.id, { onDelete: "cascade" }),
+ name: text("name").notNull(),
+ category: text("category").notNull().default("habit"),
+ defaultMinutes: integer("defaultMinutes").notNull().default(10),
+ defaultPoints: integer("defaultPoints").notNull().default(10),
+ defaultCalories: integer("defaultCalories").notNull().default(0),
+ icon: text("icon").notNull().default("custom"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ ownerIdx: index("activity_templates_owner_idx").on(table.ownerUserId),
+ }),
+);
+
+export const activityLogs = pgTable(
+ "activity_logs",
+ {
+ id: text("id").primaryKey(),
+ userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
+ templateId: text("templateId").references(() => activityTemplates.id, { onDelete: "set null" }),
+ date: date("date").notNull(),
+ name: text("name").notNull(),
+ minutes: integer("minutes").notNull().default(0),
+ points: integer("points").notNull().default(0),
+ calories: integer("calories").notNull().default(0),
+ source: activitySourceEnum("source").notNull().default("manual"),
+ externalId: text("externalId"),
+ note: text("note"),
+ createdAt: timestamp("createdAt").notNull().defaultNow(),
+ },
+ (table) => ({
+ userDateIdx: index("activity_logs_user_date_idx").on(table.userId, table.date),
+ externalUnique: uniqueIndex("activity_logs_source_external_unique").on(table.userId, table.source, table.externalId),
+ }),
+);
diff --git a/turbo.json b/turbo.json
index 1d85cce..31e0be4 100644
--- a/turbo.json
+++ b/turbo.json
@@ -3,6 +3,7 @@
"globalEnv": [
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS",
+ "NEXT_PUBLIC_HELP_URL",
"TRUSTED_ORIGINS",
"PLATFORM_ADMIN_EMAILS",
"BETTER_AUTH_BASE_URL",
@@ -10,13 +11,16 @@
"API_PORT",
"DATABASE_URL",
"DATABASE_REQUIRE_SSL",
+ "ENABLE_DB_MIGRATIONS",
"ADMIN_EMAIL",
+ "ADMIN_INITIAL_PASSWORD",
"PASSKEY_RP_ID",
"PASSKEY_RP_NAME",
"PUBLIC_ASSETS_BASE_URL",
"TRUST_PROXY_FORWARD_HEADERS",
"HEALTH_AUDIT_LOGS",
- "ALLOW_SEED_SIGNUP"
+ "ALLOW_SEED_SIGNUP",
+ "DEMO_RECIPE_OWNER_EMAIL"
],
"tasks": {
"dev": { "cache": false, "persistent": true },