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:
Till JS 2026-03-28 16:16:57 +01:00
parent 4d26196590
commit d3d11e661d
30 changed files with 1161 additions and 221 deletions

View 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"
}
}

View 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 };

View 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;

View 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;

View 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;

View 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;

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}