Refactor: CI, Schema-Split, 2FA-Optimierung und Code-Hygiene
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
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:
parent
2436dd9cac
commit
0031268416
23 changed files with 1374 additions and 1063 deletions
79
.env.example
79
.env.example
|
|
@ -1,29 +1,80 @@
|
|||
# =============================================================================
|
||||
# Hellth Hub - Beispiel-Umgebungsvariablen
|
||||
# Kopiere diese Datei nach `.env` und ersetze alle Secrets durch echte Werte.
|
||||
# Verwendung der Variablen ist in turbo.json deklariert; alle hier gelisteten
|
||||
# Werte werden von packages/api, packages/db oder apps/admin gelesen.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Datenbank
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL=postgresql://hellth:hellth_dev@localhost:5432/hellth_hub
|
||||
|
||||
# Better Auth, in Produktion durch starkes Secret ersetzen
|
||||
BETTER_AUTH_SECRET=change-me-in-production-use-openssl-rand-base64-32
|
||||
BETTER_AUTH_BASE_URL=http://localhost:3002
|
||||
PASSKEY_RP_NAME=Hellth Hub
|
||||
|
||||
# API
|
||||
API_PORT=3002
|
||||
TRUSTED_ORIGINS=http://localhost:3001
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
TRUST_PROXY_FORWARD_HEADERS=false
|
||||
# `true` erzwingt SSL gegenueber Postgres. In Produktion empfohlen.
|
||||
DATABASE_REQUIRE_SSL=false
|
||||
# Wenn `true`, fuehrt die API beim Start automatisch ausstehende Migrationen aus.
|
||||
ENABLE_DB_MIGRATIONS=false
|
||||
|
||||
# Plattform-Admin, kommasepariert
|
||||
# -----------------------------------------------------------------------------
|
||||
# Better Auth (Login, Session-Cookies, 2FA)
|
||||
# -----------------------------------------------------------------------------
|
||||
# In Produktion durch starkes Secret ersetzen, z. B. `openssl rand -base64 32`.
|
||||
BETTER_AUTH_SECRET=change-me-in-production-use-openssl-rand-base64-32
|
||||
# Basis-URL fuer Auth-Endpoints und Passkey-Origins. In Produktion zwingend https://.
|
||||
BETTER_AUTH_BASE_URL=http://localhost:3002
|
||||
# Wenn `true`, oeffnet sich Self-Service-SignUp (nur fuer Seed-Skripte aktivieren).
|
||||
ALLOW_SEED_SIGNUP=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Passkeys (WebAuthn)
|
||||
# -----------------------------------------------------------------------------
|
||||
PASSKEY_RP_ID=localhost
|
||||
PASSKEY_RP_NAME=Hellth Hub
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# API-Server
|
||||
# -----------------------------------------------------------------------------
|
||||
API_PORT=3002
|
||||
# Kommaseparierte Origin-Liste, die CORS akzeptieren soll.
|
||||
TRUSTED_ORIGINS=http://localhost:3001
|
||||
# Hinter Reverse-Proxy: `true`, damit X-Forwarded-Proto fuer HTTPS-Pruefung gelesen wird.
|
||||
TRUST_PROXY_FORWARD_HEADERS=false
|
||||
# Optional: Basis-URL fuer hochgeladene Assets (Fallback: BETTER_AUTH_BASE_URL).
|
||||
PUBLIC_ASSETS_BASE_URL=
|
||||
# Wenn `true`, schreibt der Health-Router Audit-Eintraege ins Konsolen-Log.
|
||||
HEALTH_AUDIT_LOGS=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Admin-App (Next.js)
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3002
|
||||
# Optional: URL fuer den Hilfe-Link in der UI.
|
||||
NEXT_PUBLIC_HELP_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Plattform-Admin-Zugriff
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server-Seite: berechtigt zusaetzlich zu role=admin in der DB.
|
||||
PLATFORM_ADMIN_EMAILS=admin@domain.tld
|
||||
# Frontend-Seite: gleiche Liste, fuer UI-Sichtbarkeit von Admin-Bereichen.
|
||||
NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS=admin@domain.tld
|
||||
|
||||
# Initialer Admin-User
|
||||
# -----------------------------------------------------------------------------
|
||||
# Initialer Admin-User (vom seed:admin-Script verwendet)
|
||||
# -----------------------------------------------------------------------------
|
||||
ADMIN_EMAIL=admin@domain.tld
|
||||
ADMIN_INITIAL_PASSWORD=please-set-a-strong-password-with-16-plus-chars
|
||||
|
||||
# Wearables: Fitbit OAuth, optional
|
||||
# -----------------------------------------------------------------------------
|
||||
# Demo-Daten (optional, vom seed:demo-recipes-Script verwendet)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wenn gesetzt, werden Demo-Rezepte diesem Benutzer zugeordnet.
|
||||
DEMO_RECIPE_OWNER_EMAIL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Wearables: Fitbit OAuth (optional, derzeit vorbereitet)
|
||||
# -----------------------------------------------------------------------------
|
||||
FITBIT_CLIENT_ID=
|
||||
FITBIT_CLIENT_SECRET=
|
||||
FITBIT_REDIRECT_URI=http://localhost:3002/api/wearables/fitbit/callback
|
||||
# 32-Byte Base64-Key zur Verschluesselung von Wearable-Access-Tokens in der DB.
|
||||
WEARABLE_TOKEN_ENCRYPTION_KEY=change-me-32-byte-base64-key
|
||||
|
|
|
|||
68
.forgejo/workflows/ci.yml
Normal file
68
.forgejo/workflows/ci.yml
Normal 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
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ build/
|
|||
.turbo/
|
||||
.cache/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
|
|
|||
24
apps/admin/eslint.config.mjs
Normal file
24
apps/admin/eslint.config.mjs
Normal 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;
|
||||
|
|
@ -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 }],
|
||||
};
|
||||
|
||||
|
|
|
|||
2
apps/admin/next-env.d.ts
vendored
2
apps/admin/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
400
packages/api/src/trpc/routers/health/_shared.ts
Normal file
400
packages/api/src/trpc/routers/health/_shared.ts
Normal 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>,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
103
packages/db/src/schema/community.ts
Normal file
103
packages/db/src/schema/community.ts
Normal 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] }),
|
||||
}),
|
||||
);
|
||||
104
packages/db/src/schema/foods.ts
Normal file
104
packages/db/src/schema/foods.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
128
packages/db/src/schema/profiles.ts
Normal file
128
packages/db/src/schema/profiles.ts
Normal 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;
|
||||
109
packages/db/src/schema/recipes.ts
Normal file
109
packages/db/src/schema/recipes.ts
Normal 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;
|
||||
48
packages/db/src/schema/shopping.ts
Normal file
48
packages/db/src/schema/shopping.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
94
packages/db/src/schema/stores.ts
Normal file
94
packages/db/src/schema/stores.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
128
packages/db/src/schema/tracking.ts
Normal file
128
packages/db/src/schema/tracking.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue