feat(nutriphi): migrate from Supabase to PostgreSQL + Hetzner S3

- Add nutriphi-database package with Drizzle ORM
  - meals and nutrition_goals schemas
  - PostgreSQL 16 Docker setup
  - Drizzle Kit configuration

- Migrate backend from Supabase to Drizzle
  - Add DatabaseModule with connection pooling
  - Add StorageService for Hetzner Object Storage (S3-compatible)
  - Update MealsService with Drizzle queries
  - Add /api/meals/upload endpoint for image upload + analysis

- Update web app to use backend for uploads
  - Remove Supabase Storage direct upload
  - Update uploadService to send images to backend
  - Remove Supabase dependencies from package.json
  - Simplify hooks.server.ts

- Add Coolify deployment configuration
  - Dockerfile for production build
  - docker-compose.coolify.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 17:52:14 +01:00
parent ce71db2fc0
commit 6537863696
156 changed files with 15236 additions and 170 deletions

View file

@ -0,0 +1,5 @@
# Local development
DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435/nutriphi
# Or use project-specific variable
NUTRIPHI_DATABASE_URL=postgresql://nutriphi:nutriphi_dev_password@localhost:5435/nutriphi

View file

@ -0,0 +1,36 @@
services:
postgres:
image: postgres:16-alpine
container_name: nutriphi-postgres
restart: unless-stopped
ports:
- '5435:5432'
environment:
POSTGRES_DB: nutriphi
POSTGRES_USER: nutriphi
POSTGRES_PASSWORD: nutriphi_dev_password
volumes:
- nutriphi_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U nutriphi -d nutriphi']
interval: 5s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4:latest
container_name: nutriphi-pgadmin
restart: unless-stopped
ports:
- '5052:80'
environment:
PGADMIN_DEFAULT_EMAIL: admin@nutriphi.local
PGADMIN_DEFAULT_PASSWORD: admin
volumes:
- nutriphi_pgadmin_data:/var/lib/pgadmin
depends_on:
- postgres
volumes:
nutriphi_postgres_data:
nutriphi_pgadmin_data:

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './dist/schema/index.js',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || process.env.NUTRIPHI_DATABASE_URL || '',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,54 @@
{
"name": "@manacore/nutriphi-database",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"import": "./dist/schema/index.js",
"require": "./dist/schema/index.js",
"default": "./dist/schema/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.js",
"require": "./dist/client.js",
"default": "./dist/client.js"
}
},
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"prepare": "pnpm build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f postgres",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"db:push": "dotenv -- drizzle-kit push --force",
"db:studio": "dotenv -- drizzle-kit studio",
"db:reset": "docker compose down -v && docker compose up -d && sleep 3 && pnpm db:push",
"db:test": "dotenv -- tsx src/test-connection.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"dotenv-cli": "^7.4.0",
"drizzle-kit": "^0.28.0",
"tsx": "^4.19.0",
"typescript": "^5.7.3",
"@types/node": "^22.10.0"
}
}

View file

@ -0,0 +1,97 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index.js';
// Singleton instance for the database client
let dbInstance: ReturnType<typeof drizzle<typeof schema>> | null = null;
let pgClient: ReturnType<typeof postgres> | null = null;
/**
* Get the database URL from environment variables
*/
function getDatabaseUrl(): string {
const url = process.env.DATABASE_URL || process.env.NUTRIPHI_DATABASE_URL;
if (!url) {
throw new Error(
'Database URL not found. Set DATABASE_URL or NUTRIPHI_DATABASE_URL environment variable.'
);
}
return url;
}
/**
* Create a new database client
* Uses connection pooling with sensible defaults for serverless environments
*/
export function createClient(connectionString?: string) {
const url = connectionString || getDatabaseUrl();
const client = postgres(url, {
max: 10, // Maximum connections in the pool
idle_timeout: 20, // Close idle connections after 20 seconds
connect_timeout: 10, // Connection timeout in seconds
prepare: false, // Disable prepared statements for serverless
});
return drizzle(client, { schema });
}
/**
* Get the singleton database instance
* Creates a new instance if one doesn't exist
*/
export function getDb() {
if (!dbInstance) {
const url = getDatabaseUrl();
pgClient = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
prepare: false,
});
dbInstance = drizzle(pgClient, { schema });
}
return dbInstance;
}
/**
* Close the database connection
* Should be called when shutting down the application
*/
export async function closeDb() {
if (pgClient) {
await pgClient.end();
pgClient = null;
dbInstance = null;
}
}
// Export the database type for typing purposes
export type Database = ReturnType<typeof createClient>;
// Re-export commonly used Drizzle utilities
export {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
} from 'drizzle-orm';

View file

@ -0,0 +1,32 @@
// Database client exports
export { createClient, getDb, closeDb, type Database } from './client.js';
// Re-export Drizzle utilities
export {
eq,
ne,
gt,
gte,
lt,
lte,
and,
or,
not,
inArray,
notInArray,
isNull,
isNotNull,
like,
ilike,
sql,
asc,
desc,
count,
sum,
avg,
min,
max,
} from './client.js';
// Schema exports
export * from './schema/index.js';

View file

@ -0,0 +1,21 @@
import { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';
/**
* Nutrition goals table - stores user's daily nutrition targets
*/
export const nutritionGoals = pgTable('nutrition_goals', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
caloriesTarget: integer('calories_target').notNull(),
proteinTarget: integer('protein_target').notNull(),
carbsTarget: integer('carbs_target').notNull(),
fatTarget: integer('fat_target').notNull(),
fiberTarget: integer('fiber_target'),
sugarLimit: integer('sugar_limit'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Type exports
export type NutritionGoal = typeof nutritionGoals.$inferSelect;
export type NewNutritionGoal = typeof nutritionGoals.$inferInsert;

View file

@ -0,0 +1,5 @@
// Meal schema and types
export { meals, type Meal, type NewMeal, type FoodItem } from './meals.js';
// Goals schema and types
export { nutritionGoals, type NutritionGoal, type NewNutritionGoal } from './goals.js';

View file

@ -0,0 +1,67 @@
import {
pgTable,
uuid,
text,
integer,
real,
timestamp,
index,
jsonb,
} from 'drizzle-orm/pg-core';
/**
* Meals table - stores all meal entries with nutrition data
*/
export const meals = pgTable(
'meals',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
foodName: text('food_name').notNull(),
imageUrl: text('image_url'),
storagePath: text('storage_path'), // R2 path for deletion
calories: real('calories').default(0),
protein: real('protein').default(0),
carbohydrates: real('carbohydrates').default(0),
fat: real('fat').default(0),
fiber: real('fiber').default(0),
sugar: real('sugar').default(0),
sodium: real('sodium').default(0),
servingSize: text('serving_size'),
mealType: text('meal_type'), // breakfast | lunch | dinner | snack
analysisStatus: text('analysis_status').default('pending'), // pending | completed | failed | manual
healthScore: integer('health_score'), // 1-10
healthCategory: text('health_category'), // very_healthy | healthy | moderate | unhealthy
notes: text('notes'),
userRating: integer('user_rating'), // 1-5
foodItems: jsonb('food_items').$type<FoodItem[]>().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('meals_user_id_idx').on(table.userId),
index('meals_created_at_idx').on(table.createdAt),
index('meals_user_created_idx').on(table.userId, table.createdAt),
]
);
/**
* Food item type for meal ingredients
*/
export interface FoodItem {
id: string;
name: string;
category: 'protein' | 'vegetable' | 'grain' | 'fruit' | 'dairy' | 'fat' | 'processed' | 'beverage';
portionSize: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
fiber?: number;
sugar?: number;
confidence?: number;
}
// Type exports
export type Meal = typeof meals.$inferSelect;
export type NewMeal = typeof meals.$inferInsert;

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}