mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 12:21:23 +02:00
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:
parent
ce71db2fc0
commit
6537863696
156 changed files with 15236 additions and 170 deletions
97
packages/nutriphi-database/src/client.ts
Normal file
97
packages/nutriphi-database/src/client.ts
Normal 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';
|
||||
32
packages/nutriphi-database/src/index.ts
Normal file
32
packages/nutriphi-database/src/index.ts
Normal 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';
|
||||
21
packages/nutriphi-database/src/schema/goals.ts
Normal file
21
packages/nutriphi-database/src/schema/goals.ts
Normal 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;
|
||||
5
packages/nutriphi-database/src/schema/index.ts
Normal file
5
packages/nutriphi-database/src/schema/index.ts
Normal 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';
|
||||
67
packages/nutriphi-database/src/schema/meals.ts
Normal file
67
packages/nutriphi-database/src/schema/meals.ts
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue