Refactor: CI, Schema-Split, 2FA-Optimierung und Code-Hygiene
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run

CI-Pipeline (Forgejo Actions):
- .forgejo/workflows/ci.yml: lint + typecheck + test + admin build
- typecheck-Scripts in packages/api, packages/db, apps/admin ergaenzt
- ESLint FlatConfig (eslint-config-next 16) in apps/admin angelegt
- React-19-Hook-Findings im bestehenden Code als Warnings markiert
  (set-state-in-effect, preserve-manual-memoization, no-unused-vars)

DB-Schema-Split:
- packages/db/src/schema/health.ts (636 Zeilen, Monolith) entfernt und auf
  7 Domain-Module aufgeteilt: profiles, foods, stores, recipes, tracking,
  shopping, community. index.ts re-exportiert alles.

API-Refactoring:
- 2FA-Middleware: 2 DB-Queries pro Admin-Call sind jetzt lazy + per-Request
  gecached. Bei tRPC-Batch-Requests mit mehreren protected Procedures wird
  der 2FA-State nur einmal geladen (siehe context.ts getTwoFactorState).
- any-Typen in trpc/init.ts und trpc/routers/health.ts entfernt; Drizzle
  inferiert Closure-Typen automatisch.
- packages/api/src/trpc/routers/health/_shared.ts: alle Helper-Funktionen
  und Zod-Input-Schemas aus health.ts extrahiert. health.ts: 2419 -> 2061
  Zeilen. Vorbereitung fuer Procedure-Split in Folge-Session.

Konfiguration:
- .env.example um alle in turbo.json deklarierten Env-Vars erweitert,
  jeweils mit Kontext-Kommentar.
- turbo.json um real genutzte Env-Vars erweitert: NEXT_PUBLIC_HELP_URL,
  ENABLE_DB_MIGRATIONS, ADMIN_INITIAL_PASSWORD, DEMO_RECIPE_OWNER_EMAIL.
- .gitignore: *.tsbuildinfo (TypeScript-Build-Cache).
- apps/admin/lib/hellth-data.test.ts: fehlendes Recipe.minutes Feld in Mocks.

Offene Folge-Sessions:
- Vollstaendiger Procedure-Split von routers/health.ts in Sub-Files
- Aufteilung von apps/admin/lib/hellth-data.ts (1149 Zeilen) in Domain-Module

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastian Mayer 2026-05-27 23:34:39 +02:00
parent 2436dd9cac
commit 0031268416
23 changed files with 1374 additions and 1063 deletions

View file

@ -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

68
.forgejo/workflows/ci.yml Normal file
View file

@ -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

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ build/
.turbo/
.cache/
coverage/
*.tsbuildinfo
# Logs
*.log

View file

@ -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;

View file

@ -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 }],
};

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View file

@ -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",

View file

@ -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"

View file

@ -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<TwoFactorState> {
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<TwoFactorState> | null = null;
const getTwoFactorState = (): Promise<TwoFactorState> => {
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,
};
}

View file

@ -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) {

View file

@ -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<string, unknown> = {}) {
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<typeof pointSettingDefaults>;
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<string, number | string | undefined>;
}) {
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<string, unknown>,
};
}
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)),

View file

@ -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<string, unknown> = {}) {
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<typeof pointSettingDefaults>;
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<string, number | string | undefined>;
}) {
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<string, unknown>,
};
}

View file

@ -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",

View file

@ -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<string[]>().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<Record<string, unknown>>().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] }),
}),
);

View file

@ -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<Record<string, unknown>>().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<Record<string, unknown>>().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;

View file

@ -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<string[]>().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<Record<string, unknown>>().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<Record<string, unknown>>().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<Record<string, unknown>>().notNull().default({}),
proposedDepartments: jsonb("proposedDepartments").$type<Array<Record<string, unknown>>>().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<string[]>().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<string[]>().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<Record<string, unknown>>().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<string[]>().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;

View file

@ -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";

View file

@ -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<string[]>().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<string[]>().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;

View file

@ -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;

View file

@ -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),
}),
);

View file

@ -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<Record<string, unknown>>().notNull().default({}),
proposedDepartments: jsonb("proposedDepartments").$type<Array<Record<string, unknown>>>().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<string[]>().notNull().default([]),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.foodId, table.storeId] }),
storeIdx: index("food_stores_store_idx").on(table.storeId),
}),
);

View file

@ -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),
}),
);

View file

@ -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 },