mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 06:53:38 +02:00
feat(apps): create Hono compute servers for Traces, Planta, NutriPhi
Add lightweight Hono + Bun servers for server-only compute endpoints. CRUD is handled by mana-sync, these handle AI + file upload only. Traces: AI guide generation, location sync (Port 3026) Planta: Photo upload (S3), AI plant analysis (Port 3022) NutriPhi: AI meal analysis (photo+text), recommendations (Port 3023) Each uses @manacore/shared-hono for auth/health/errors. ~100-200 LOC. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d26196590
commit
d3d11e661d
30 changed files with 1161 additions and 221 deletions
20
apps/planta/apps/server/package.json
Normal file
20
apps/planta/apps/server/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@planta/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-hono": "workspace:*",
|
||||
"@manacore/shared-storage": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
104
apps/planta/apps/server/src/index.ts
Normal file
104
apps/planta/apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Planta Hono Server — Compute-only endpoints
|
||||
*
|
||||
* Server-side logic:
|
||||
* - Photo upload to S3/MinIO
|
||||
* - AI plant analysis via mana-llm (Gemini Vision)
|
||||
* - Watering upcoming computation
|
||||
*
|
||||
* CRUD for plants, photos, watering handled by mana-sync.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { authMiddleware, healthRoute, errorHandler, notFoundHandler } from '@manacore/shared-hono';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3022', 10);
|
||||
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
|
||||
const CORS_ORIGINS = (process.env.CORS_ORIGINS || 'http://localhost:5173').split(',');
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.onError(errorHandler);
|
||||
app.notFound(notFoundHandler);
|
||||
app.use('*', cors({ origin: CORS_ORIGINS, credentials: true }));
|
||||
app.route('/health', healthRoute('planta-server'));
|
||||
app.use('/api/*', authMiddleware());
|
||||
|
||||
// ─── Photo Upload (server-only: S3 storage) ─────────────────
|
||||
|
||||
app.post('/api/v1/photos/upload', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const plantId = formData.get('plantId') as string | null;
|
||||
|
||||
if (!file) return c.json({ error: 'No file provided' }, 400);
|
||||
if (file.size > 10 * 1024 * 1024) return c.json({ error: 'File too large (max 10MB)' }, 400);
|
||||
|
||||
try {
|
||||
const { createPlantaStorage, generateUserFileKey, getContentType } = await import(
|
||||
'@manacore/shared-storage'
|
||||
);
|
||||
const storage = createPlantaStorage();
|
||||
const key = generateUserFileKey(userId, file.name);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const result = await storage.upload(key, buffer, {
|
||||
contentType: getContentType(file.name),
|
||||
public: true,
|
||||
});
|
||||
|
||||
return c.json({ storagePath: key, publicUrl: result.url, plantId }, 201);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
return c.json({ error: 'Upload failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── AI Analysis (server-only: Gemini Vision) ───────────────
|
||||
|
||||
app.post('/api/v1/analysis/identify', async (c) => {
|
||||
const { photoUrl } = await c.req.json();
|
||||
if (!photoUrl) return c.json({ error: 'photoUrl required' }, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${LLM_URL}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Du bist ein Pflanzenexperte. Analysiere das Bild und gib JSON zurück: {scientificName, commonNames[], confidence, healthAssessment, wateringAdvice, lightAdvice, generalTips[]}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'Analysiere diese Pflanze.' },
|
||||
{ type: 'image_url', image_url: { url: photoUrl } },
|
||||
],
|
||||
},
|
||||
],
|
||||
model: process.env.VISION_MODEL || 'gemini-2.0-flash',
|
||||
response_format: { type: 'json_object' },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) return c.json({ error: 'AI analysis failed' }, 502);
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
const analysis = typeof content === 'string' ? JSON.parse(content) : content;
|
||||
|
||||
return c.json(analysis);
|
||||
} catch (err) {
|
||||
console.error('Analysis failed:', err);
|
||||
return c.json({ error: 'Analysis failed' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`planta-server starting on port ${PORT}...`);
|
||||
|
||||
export default { port: PORT, fetch: app.fetch };
|
||||
40
apps/planta/apps/server/src/plant-analyses.schema.ts
Normal file
40
apps/planta/apps/server/src/plant-analyses.schema.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { pgTable, uuid, text, timestamp, jsonb, integer } from 'drizzle-orm/pg-core';
|
||||
import { plantPhotos } from './plant-photos.schema';
|
||||
import { plants } from './plants.schema';
|
||||
|
||||
export const plantAnalyses = pgTable('plant_analyses', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
photoId: uuid('photo_id')
|
||||
.references(() => plantPhotos.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
plantId: uuid('plant_id').references(() => plants.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
// AI Analysis Results
|
||||
identifiedSpecies: text('identified_species'),
|
||||
scientificName: text('scientific_name'),
|
||||
commonNames: jsonb('common_names').$type<string[]>(),
|
||||
confidence: integer('confidence'),
|
||||
|
||||
// Plant condition
|
||||
healthAssessment: text('health_assessment'),
|
||||
healthDetails: text('health_details'),
|
||||
issues: jsonb('issues').$type<string[]>(),
|
||||
|
||||
// Care recommendations
|
||||
wateringAdvice: text('watering_advice'),
|
||||
lightAdvice: text('light_advice'),
|
||||
fertilizingAdvice: text('fertilizing_advice'),
|
||||
generalTips: jsonb('general_tips').$type<string[]>(),
|
||||
|
||||
// Raw AI response for debugging
|
||||
rawResponse: jsonb('raw_response'),
|
||||
model: text('model'),
|
||||
tokensUsed: integer('tokens_used'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type PlantAnalysis = typeof plantAnalyses.$inferSelect;
|
||||
export type NewPlantAnalysis = typeof plantAnalyses.$inferInsert;
|
||||
30
apps/planta/apps/server/src/plant-photos.schema.ts
Normal file
30
apps/planta/apps/server/src/plant-photos.schema.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
import { plants } from './plants.schema';
|
||||
|
||||
export const plantPhotos = pgTable('plant_photos', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
plantId: uuid('plant_id').references(() => plants.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
// Storage
|
||||
storagePath: text('storage_path').notNull(),
|
||||
publicUrl: text('public_url'),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type'),
|
||||
fileSize: integer('file_size'),
|
||||
|
||||
// Image metadata
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
|
||||
// Flags
|
||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||
isAnalyzed: boolean('is_analyzed').default(false).notNull(),
|
||||
|
||||
// Timestamps
|
||||
takenAt: timestamp('taken_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type PlantPhoto = typeof plantPhotos.$inferSelect;
|
||||
export type NewPlantPhoto = typeof plantPhotos.$inferInsert;
|
||||
32
apps/planta/apps/server/src/plants.schema.ts
Normal file
32
apps/planta/apps/server/src/plants.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const plants = pgTable('plants', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
// Plant identity
|
||||
name: text('name').notNull(),
|
||||
scientificName: text('scientific_name'),
|
||||
commonName: text('common_name'),
|
||||
species: text('species'),
|
||||
|
||||
// Care info (from AI)
|
||||
lightRequirements: text('light_requirements'),
|
||||
wateringFrequencyDays: integer('watering_frequency_days'),
|
||||
humidity: text('humidity'),
|
||||
temperature: text('temperature'),
|
||||
soilType: text('soil_type'),
|
||||
careNotes: text('care_notes'),
|
||||
|
||||
// Status
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
healthStatus: text('health_status'),
|
||||
|
||||
// Timestamps
|
||||
acquiredAt: timestamp('acquired_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Plant = typeof plants.$inferSelect;
|
||||
export type NewPlant = typeof plants.$inferInsert;
|
||||
45
apps/planta/apps/server/src/watering.schema.ts
Normal file
45
apps/planta/apps/server/src/watering.schema.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
import { plants } from './plants.schema';
|
||||
|
||||
export const wateringSchedules = pgTable('watering_schedules', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
plantId: uuid('plant_id')
|
||||
.references(() => plants.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
// Schedule config
|
||||
frequencyDays: integer('frequency_days').notNull(),
|
||||
|
||||
// Tracking
|
||||
lastWateredAt: timestamp('last_watered_at', { withTimezone: true }),
|
||||
nextWateringAt: timestamp('next_watering_at', { withTimezone: true }),
|
||||
|
||||
// Notification preferences
|
||||
reminderEnabled: boolean('reminder_enabled').default(true).notNull(),
|
||||
reminderHoursBefore: integer('reminder_hours_before').default(24),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type WateringSchedule = typeof wateringSchedules.$inferSelect;
|
||||
export type NewWateringSchedule = typeof wateringSchedules.$inferInsert;
|
||||
|
||||
// Watering log for history tracking
|
||||
export const wateringLogs = pgTable('watering_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
plantId: uuid('plant_id')
|
||||
.references(() => plants.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
wateredAt: timestamp('watered_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
notes: text('notes'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type WateringLog = typeof wateringLogs.$inferSelect;
|
||||
export type NewWateringLog = typeof wateringLogs.$inferInsert;
|
||||
11
apps/planta/apps/server/tsconfig.json
Normal file
11
apps/planta/apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue