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
|
# Datenbank
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
DATABASE_URL=postgresql://hellth:hellth_dev@localhost:5432/hellth_hub
|
DATABASE_URL=postgresql://hellth:hellth_dev@localhost:5432/hellth_hub
|
||||||
|
# `true` erzwingt SSL gegenueber Postgres. In Produktion empfohlen.
|
||||||
# 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
|
|
||||||
DATABASE_REQUIRE_SSL=false
|
DATABASE_REQUIRE_SSL=false
|
||||||
|
# Wenn `true`, fuehrt die API beim Start automatisch ausstehende Migrationen aus.
|
||||||
ENABLE_DB_MIGRATIONS=false
|
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
|
PLATFORM_ADMIN_EMAILS=admin@domain.tld
|
||||||
|
# Frontend-Seite: gleiche Liste, fuer UI-Sichtbarkeit von Admin-Bereichen.
|
||||||
NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS=admin@domain.tld
|
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_EMAIL=admin@domain.tld
|
||||||
ADMIN_INITIAL_PASSWORD=please-set-a-strong-password-with-16-plus-chars
|
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_ID=
|
||||||
FITBIT_CLIENT_SECRET=
|
FITBIT_CLIENT_SECRET=
|
||||||
FITBIT_REDIRECT_URI=http://localhost:3002/api/wearables/fitbit/callback
|
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
|
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/
|
.turbo/
|
||||||
.cache/
|
.cache/
|
||||||
coverage/
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.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",
|
id: "vegan",
|
||||||
name: "Tofu Bowl",
|
name: "Tofu Bowl",
|
||||||
portions: 1,
|
portions: 1,
|
||||||
|
minutes: 10,
|
||||||
ingredients: [
|
ingredients: [
|
||||||
{ foodId: "tofu", amountG: 200 },
|
{ foodId: "tofu", amountG: 200 },
|
||||||
{ foodId: "oats", amountG: 50 },
|
{ foodId: "oats", amountG: 50 },
|
||||||
|
|
@ -142,6 +143,7 @@ test("classifyRecipe detects vegetarian vegan and macro criteria", () => {
|
||||||
id: "chicken",
|
id: "chicken",
|
||||||
name: "Hähnchen Bowl",
|
name: "Hähnchen Bowl",
|
||||||
portions: 1,
|
portions: 1,
|
||||||
|
minutes: 15,
|
||||||
ingredients: [{ foodId: "chicken", amountG: 180 }],
|
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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start --port 3001",
|
"start": "next start --port 3001",
|
||||||
"test": "node --import tsx --test lib/**/*.test.ts",
|
"test": "node --import tsx --test lib/**/*.test.ts",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.4.18",
|
"@better-auth/passkey": "^1.4.18",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch --import tsx src/index.ts",
|
"dev": "node --watch --import tsx src/index.ts",
|
||||||
"test": "node --env-file ../../.env --import tsx --test src/**/*.test.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:admin": "node --import tsx src/scripts/seed-admin.ts",
|
||||||
"seed:health": "node --import tsx src/scripts/seed-health.ts",
|
"seed:health": "node --import tsx src/scripts/seed-health.ts",
|
||||||
"seed:demo-recipes": "node --import tsx src/scripts/seed-demo-recipes.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 type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
|
||||||
import { auth } from "../lib/auth";
|
import { auth } from "../lib/auth";
|
||||||
import { isPlatformAdminUser, parseConfiguredAdminEmails } from "../lib/runtime-security";
|
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) {
|
export async function createContext(opts: FetchCreateContextFnOptions) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: opts.req.headers,
|
headers: opts.req.headers,
|
||||||
|
|
@ -18,6 +47,19 @@ export async function createContext(opts: FetchCreateContextFnOptions) {
|
||||||
configuredAdminEmails: platformAdminEmails,
|
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 {
|
return {
|
||||||
db,
|
db,
|
||||||
headers: opts.req.headers,
|
headers: opts.req.headers,
|
||||||
|
|
@ -25,6 +67,7 @@ export async function createContext(opts: FetchCreateContextFnOptions) {
|
||||||
user,
|
user,
|
||||||
userRole,
|
userRole,
|
||||||
isPlatformAdmin,
|
isPlatformAdmin,
|
||||||
|
getTwoFactorState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,21 +40,9 @@ const enforceTwoFactorForPrivilegedUsers = t.middleware(async ({ ctx, next, path
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbUser = await ctx.db.query.user.findFirst({
|
// Lazy + per-Request gecached - bei tRPC-Batches mit mehreren protected Calls
|
||||||
where: (row: any, { eq }: any) => eq(row.id, user.id),
|
// wird die 2FA-Status-Query nur einmal ausgefuehrt (siehe context.ts).
|
||||||
columns: {
|
const { dbUser, twoFactorRow } = await ctx.getTwoFactorState();
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasTwoFactor = !!dbUser?.twoFactorEnabled && !!twoFactorRow?.enabled && !dbUser?.twoFactorRecoveryRequired;
|
const hasTwoFactor = !!dbUser?.twoFactorEnabled && !!twoFactorRow?.enabled && !dbUser?.twoFactorRecoveryRequired;
|
||||||
if (!hasTwoFactor) {
|
if (!hasTwoFactor) {
|
||||||
|
|
|
||||||
|
|
@ -1,401 +1,44 @@
|
||||||
import { schema } from "@hellth/db";
|
import { schema } from "@hellth/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { buildDailyCoachSignals } from "../../lib/health-coach";
|
import { buildDailyCoachSignals } from "../../lib/health-coach";
|
||||||
import { platformProcedure, protectedProcedure, router } from "../init";
|
import { platformProcedure, protectedProcedure, router } from "../init";
|
||||||
|
import {
|
||||||
const dateKeySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
addDays,
|
||||||
const portionUnitSchema = z.enum(["g", "ml", "tsp", "tbsp", "piece", "pack", "bottle", "cup", "slice"]);
|
auditHealthMutation,
|
||||||
const mealTypeSchema = z.enum(["breakfast", "lunch", "dinner", "snack"]);
|
averageNutritionSources,
|
||||||
const clubCategorySchema = z.enum(["walk", "run", "mobility", "strength", "bike", "other"]);
|
calcRecipeCalories,
|
||||||
const clubScopeSchema = z.enum(["local", "state", "nationwide"]);
|
classifyFoodsForDiet,
|
||||||
const clubPrivacyModeSchema = z.enum(["city", "state"]);
|
clubActivityInput,
|
||||||
|
clubProfileInput,
|
||||||
const pointSettingDefaults = {
|
dateKeySchema,
|
||||||
recipeSubmissionPoints: 25,
|
dateRange,
|
||||||
clubActivityDefaultPoints: 100,
|
foodInput,
|
||||||
};
|
foodPreferenceInput,
|
||||||
|
foodText,
|
||||||
const pointSettingsInput = z.object({
|
getPointSettings,
|
||||||
recipeSubmissionPoints: z.number().int().min(0).max(10000),
|
goalsInput,
|
||||||
clubActivityDefaultPoints: z.number().int().min(0).max(10000),
|
hasAnyWord,
|
||||||
activityTemplates: z.array(z.object({
|
id,
|
||||||
id: z.string().min(1),
|
mealTypeSchema,
|
||||||
defaultPoints: z.number().int().min(0).max(10000),
|
normalizeOpenFoodFactsProduct,
|
||||||
})).default([]),
|
normalizeSearch,
|
||||||
});
|
numberString,
|
||||||
|
nutritionSourceInput,
|
||||||
function id(prefix: string) {
|
pointSettingsInput,
|
||||||
return `${prefix}-${randomUUID()}`;
|
profileInput,
|
||||||
}
|
recipeInput,
|
||||||
|
recipeNutrition,
|
||||||
function auditHealthMutation(ctx: { user?: { id?: string } | null }, action: string, metadata: Record<string, unknown> = {}) {
|
recipeRecommendationInput,
|
||||||
if (process.env.HEALTH_AUDIT_LOGS !== "true") return;
|
recipeSubmissionReviewInput,
|
||||||
console.info("[health]", {
|
recipeSubmissionStatusSchema,
|
||||||
action,
|
startOfWeek,
|
||||||
userId: ctx.user?.id ?? null,
|
storeChangeRequestInput,
|
||||||
...metadata,
|
storeChangeReviewInput,
|
||||||
at: new Date().toISOString(),
|
storeFavoriteInput,
|
||||||
});
|
storeInput,
|
||||||
}
|
} from "./health/_shared";
|
||||||
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const healthRouter = router({
|
export const healthRouter = router({
|
||||||
pointDefaults: protectedProcedure.query(async ({ ctx }) => getPointSettings(ctx.db)),
|
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:generate": "drizzle-kit generate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.44.0",
|
"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 "./auth";
|
||||||
export * from "./enums";
|
export * from "./enums";
|
||||||
export * from "./platform-settings";
|
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": [
|
"globalEnv": [
|
||||||
"NEXT_PUBLIC_API_URL",
|
"NEXT_PUBLIC_API_URL",
|
||||||
"NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS",
|
"NEXT_PUBLIC_PLATFORM_ADMIN_EMAILS",
|
||||||
|
"NEXT_PUBLIC_HELP_URL",
|
||||||
"TRUSTED_ORIGINS",
|
"TRUSTED_ORIGINS",
|
||||||
"PLATFORM_ADMIN_EMAILS",
|
"PLATFORM_ADMIN_EMAILS",
|
||||||
"BETTER_AUTH_BASE_URL",
|
"BETTER_AUTH_BASE_URL",
|
||||||
|
|
@ -10,13 +11,16 @@
|
||||||
"API_PORT",
|
"API_PORT",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DATABASE_REQUIRE_SSL",
|
"DATABASE_REQUIRE_SSL",
|
||||||
|
"ENABLE_DB_MIGRATIONS",
|
||||||
"ADMIN_EMAIL",
|
"ADMIN_EMAIL",
|
||||||
|
"ADMIN_INITIAL_PASSWORD",
|
||||||
"PASSKEY_RP_ID",
|
"PASSKEY_RP_ID",
|
||||||
"PASSKEY_RP_NAME",
|
"PASSKEY_RP_NAME",
|
||||||
"PUBLIC_ASSETS_BASE_URL",
|
"PUBLIC_ASSETS_BASE_URL",
|
||||||
"TRUST_PROXY_FORWARD_HEADERS",
|
"TRUST_PROXY_FORWARD_HEADERS",
|
||||||
"HEALTH_AUDIT_LOGS",
|
"HEALTH_AUDIT_LOGS",
|
||||||
"ALLOW_SEED_SIGNUP"
|
"ALLOW_SEED_SIGNUP",
|
||||||
|
"DEMO_RECIPE_OWNER_EMAIL"
|
||||||
],
|
],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": { "cache": false, "persistent": true },
|
"dev": { "cache": false, "persistent": true },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue