feat(planta): add plant care tracking application

Add new Planta project for plant care management with:

Backend (NestJS):
- Plant CRUD with species, location, and care requirements
- Watering tracking and scheduling
- Photo management with S3 storage
- AI-powered plant analysis using Google Gemini Vision API
- Drizzle ORM with PostgreSQL schema

Web (SvelteKit):
- Dashboard with plant overview
- Plant detail pages with care history
- Add/edit plant forms
- Auth integration with login/register routes
- API client layer for all endpoints

Infrastructure:
- Database setup in setup-databases.sh
- MinIO bucket for plant photos
- Environment variables for port 3022

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-18 14:57:16 +01:00
parent 9afae2efd2
commit e22961e580
65 changed files with 3840 additions and 3 deletions

View file

@ -46,7 +46,7 @@ JWT_ACCESS_TOKEN_EXPIRY=15m
JWT_REFRESH_TOKEN_EXPIRY=7d
JWT_ISSUER=manacore
JWT_AUDIENCE=manacore
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:5186,http://localhost:5187,http://localhost:5188,http://localhost:5189,http://localhost:5190,http://localhost:8081
CORS_ORIGINS=http://localhost:3000,http://localhost:3002,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:5177,http://localhost:5178,http://localhost:5179,http://localhost:5180,http://localhost:5181,http://localhost:5182,http://localhost:5183,http://localhost:5184,http://localhost:5185,http://localhost:5186,http://localhost:5187,http://localhost:5188,http://localhost:5189,http://localhost:5190,http://localhost:5191,http://localhost:8081
CREDITS_SIGNUP_BONUS=150
CREDITS_DAILY_FREE=5
RATE_LIMIT_TTL=60
@ -274,6 +274,17 @@ INVENTORY_S3_PUBLIC_URL=http://localhost:9000/inventory-storage
TECHBASE_BACKEND_PORT=3021
TECHBASE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/techbase
# ============================================
# PLANTA PROJECT
# ============================================
PLANTA_BACKEND_PORT=3022
PLANTA_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/planta
PLANTA_S3_PUBLIC_URL=http://localhost:9000/planta-storage
# Google Gemini API for plant vision analysis
PLANTA_GEMINI_API_KEY=AIzaSyC_-hPWpVttTlqJdU4jbXR5H0OAnRi2LgI
# ============================================
# WORLDREAM GAME
# ============================================

167
apps/planta/CLAUDE.md Normal file
View file

@ -0,0 +1,167 @@
# Planta Project Guide
## Project Structure
```
apps/planta/
├── apps/
│ ├── backend/ # NestJS API server (@planta/backend)
│ └── web/ # SvelteKit web application (@planta/web)
├── packages/
│ └── shared/ # Shared types, utils (@planta/shared)
└── package.json
```
## Commands
### Root Level (from monorepo root)
```bash
pnpm planta:dev # Run all planta apps
pnpm dev:planta:web # Start web app
pnpm dev:planta:backend # Start backend server
pnpm dev:planta:app # Start web + backend together
pnpm dev:planta:full # Start auth + backend + web with DB setup
```
### Backend (apps/planta/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
### Web App (apps/planta/apps/web)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
## Technology Stack
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
- **AI**: Google Gemini Vision for plant analysis
- **Storage**: MinIO (local) / Hetzner S3 (prod)
- **Auth**: Mana Core Auth (JWT)
- **Types**: TypeScript 5.x
## Architecture
### Core Flow
1. User uploads plant photo
2. Photo stored in S3/MinIO
3. Gemini Vision analyzes the image
4. Plant profile created with care recommendations
5. Watering schedule tracked
### Backend API Endpoints
| Endpoint | Method | Description |
| ------------------------------- | ------ | ------------------------ |
| `/api/health` | GET | Health check |
| `/api/plants` | GET | Get user's plants |
| `/api/plants` | POST | Create new plant |
| `/api/plants/:id` | GET | Get plant details |
| `/api/plants/:id` | PUT | Update plant |
| `/api/plants/:id` | DELETE | Delete plant |
| `/api/photos/upload` | POST | Upload plant photo |
| `/api/photos/:id` | DELETE | Delete photo |
| `/api/analysis/identify` | POST | Analyze photo with AI |
| `/api/analysis/:photoId` | GET | Get analysis results |
| `/api/watering/upcoming` | GET | Plants needing water |
| `/api/watering/:plantId/water` | POST | Log watering event |
### Database Schema
**plants** - User's plants
- `id` (UUID) - Primary key
- `user_id` (TEXT) - User reference
- `name` (TEXT) - Plant nickname
- `scientific_name` (TEXT) - From AI analysis
- `common_name` (TEXT) - Common name
- `light_requirements` (TEXT) - low/medium/bright/direct
- `watering_frequency_days` (INT) - Days between watering
- `humidity` (TEXT) - low/medium/high
- `care_notes` (TEXT) - Care tips
- `health_status` (TEXT) - healthy/needs_attention/sick
**plant_photos** - Plant photos
- `id` (UUID) - Primary key
- `plant_id` (UUID) - FK to plants
- `storage_path` (TEXT) - S3 path
- `public_url` (TEXT) - Public URL
- `is_primary` (BOOLEAN) - Primary photo flag
- `is_analyzed` (BOOLEAN) - Analysis flag
**plant_analyses** - AI analysis results
- `id` (UUID) - Primary key
- `photo_id` (UUID) - FK to plant_photos
- `identified_species` (TEXT) - Detected species
- `confidence` (INT) - 0-100 confidence
- `health_assessment` (TEXT) - Health status
- `watering_advice` (TEXT) - Watering recommendation
- `general_tips` (JSONB) - Care tips array
**watering_schedules** - Watering tracking
- `id` (UUID) - Primary key
- `plant_id` (UUID) - FK to plants
- `frequency_days` (INT) - Interval
- `last_watered_at` (TIMESTAMP) - Last watering
- `next_watering_at` (TIMESTAMP) - Next watering
### Environment Variables
#### Backend (.env)
```
NODE_ENV=development
PORT=3022
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/planta
MANA_CORE_AUTH_URL=http://localhost:3001
GOOGLE_GEMINI_API_KEY=xxx
CORS_ORIGINS=http://localhost:5173,http://localhost:5191
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=planta-storage
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
```
#### Web (.env)
```
PUBLIC_BACKEND_URL=http://localhost:3022
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Shared Package
### @planta/shared
- Types: `Plant`, `PlantPhoto`, `PlantAnalysis`, `WateringSchedule`
- Utils: Date helpers, care level formatters
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
- **Styling**: Tailwind CSS
- **Formatting**: Prettier with project config
## Important Notes
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
2. **Database**: PostgreSQL with Drizzle ORM
3. **Port**: Backend runs on port 3022 by default
4. **Storage**: Photos stored in MinIO (local) / Hetzner S3 (prod)
5. **AI**: Google Gemini Vision for plant identification

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/planta',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,60 @@
{
"name": "@planta/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@google/generative-ai": "^0.21.0",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@planta/shared": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,29 @@
import { Controller, Post, Get, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AnalysisService } from './analysis.service';
import { IsString, IsOptional } from 'class-validator';
class AnalyzePhotoDto {
@IsString()
photoId: string;
@IsOptional()
@IsString()
plantId?: string;
}
@Controller('analysis')
@UseGuards(JwtAuthGuard)
export class AnalysisController {
constructor(private readonly analysisService: AnalysisService) {}
@Post('identify')
async analyzePhoto(@CurrentUser() user: CurrentUserData, @Body() dto: AnalyzePhotoDto) {
return this.analysisService.analyzePhoto(dto.photoId, user.userId, dto.plantId);
}
@Get(':photoId')
async getAnalysis(@Param('photoId') photoId: string, @CurrentUser() user: CurrentUserData) {
return this.analysisService.getAnalysis(photoId, user.userId);
}
}

View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AnalysisController } from './analysis.controller';
import { AnalysisService } from './analysis.service';
import { VisionService } from './vision.service';
import { PhotoModule } from '../photo/photo.module';
import { PlantModule } from '../plant/plant.module';
@Module({
imports: [PhotoModule, PlantModule],
controllers: [AnalysisController],
providers: [AnalysisService, VisionService],
exports: [AnalysisService],
})
export class AnalysisModule {}

View file

@ -0,0 +1,131 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
import { plantAnalyses, plantPhotos } from '../db/schema';
import { VisionService } from './vision.service';
import { PhotoService } from '../photo/photo.service';
import { PlantService } from '../plant/plant.service';
@Injectable()
export class AnalysisService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private visionService: VisionService,
private photoService: PhotoService,
private plantService: PlantService
) {}
async analyzePhoto(photoId: string, userId: string, plantId?: string) {
// Get photo
const photo = await this.photoService.findOne(photoId, userId);
if (photo.isAnalyzed) {
// Return existing analysis
const [existing] = await this.db
.select()
.from(plantAnalyses)
.where(eq(plantAnalyses.photoId, photoId))
.limit(1);
if (existing) {
return existing;
}
}
// Download image for analysis
const imageBuffer = await this.photoService.getPhotoBuffer(photo.storagePath);
// Analyze with Gemini Vision
const result = await this.visionService.analyzePlantImage(
imageBuffer,
photo.mimeType || 'image/jpeg'
);
if (!result) {
throw new BadRequestException('Failed to analyze plant image');
}
// Store analysis
const [analysis] = await this.db
.insert(plantAnalyses)
.values({
photoId,
plantId: plantId || photo.plantId,
userId,
identifiedSpecies: result.identification.scientificName,
scientificName: result.identification.scientificName,
commonNames: result.identification.commonNames,
confidence: result.identification.confidence,
healthAssessment: result.health.status,
healthDetails: result.health.details,
issues: result.health.issues,
wateringAdvice: `Alle ${result.care.wateringFrequencyDays} Tage gießen`,
lightAdvice: this.formatLightAdvice(result.care.light),
generalTips: result.care.tips,
rawResponse: result,
model: 'gemini-2.0-flash',
})
.returning();
// Mark photo as analyzed
await this.photoService.markAnalyzed(photoId);
// If linked to a plant, update plant with analysis data
const targetPlantId = plantId || photo.plantId;
if (targetPlantId) {
await this.plantService.updateFromAnalysis(targetPlantId, {
scientificName: result.identification.scientificName,
commonName: result.identification.commonNames[0],
lightRequirements: result.care.light,
wateringFrequencyDays: result.care.wateringFrequencyDays,
humidity: result.care.humidity,
temperature: result.care.temperature,
soilType: result.care.soilType,
careNotes: result.care.tips.join('\n'),
healthStatus: this.mapHealthStatus(result.health.status),
});
}
return analysis;
}
async getAnalysis(photoId: string, userId: string) {
const [analysis] = await this.db
.select()
.from(plantAnalyses)
.where(eq(plantAnalyses.photoId, photoId))
.limit(1);
if (!analysis) {
throw new NotFoundException('Analysis not found');
}
// Verify user owns this analysis
if (analysis.userId !== userId) {
throw new NotFoundException('Analysis not found');
}
return analysis;
}
private formatLightAdvice(light: string): string {
const lightMap: Record<string, string> = {
low: 'Wenig Licht - Schattige Standorte geeignet',
medium: 'Mittleres Licht - Heller Standort ohne direkte Sonne',
bright: 'Helles Licht - Heller Standort mit indirektem Sonnenlicht',
direct: 'Direkte Sonne - Sonniger Standort mit direkter Sonneneinstrahlung',
};
return lightMap[light] || 'Mittleres Licht empfohlen';
}
private mapHealthStatus(assessment: string): string {
const statusMap: Record<string, string> = {
healthy: 'healthy',
minor_issues: 'needs_attention',
needs_care: 'needs_attention',
critical: 'sick',
};
return statusMap[assessment] || 'healthy';
}
}

View file

@ -0,0 +1,164 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
import type { AnalysisResult } from '@planta/shared';
const PLANT_ANALYSIS_PROMPT = `Du bist ein erfahrener Botaniker und Pflanzenexperte. Analysiere dieses Pflanzenfoto und erstelle einen detaillierten Steckbrief.
Antworte NUR mit validem JSON (keine Markdown-Codeblocks, kein anderer Text) in diesem Format:
{
"identification": {
"scientificName": "Botanischer Name (Gattung und Art)",
"commonNames": ["Deutscher Name", "Alternative Namen"],
"confidence": 85
},
"health": {
"status": "healthy",
"issues": [],
"details": "Beschreibung des Gesundheitszustands"
},
"care": {
"light": "bright",
"wateringFrequencyDays": 7,
"humidity": "medium",
"temperature": "18-24°C",
"soilType": "Gut durchlässige Blumenerde",
"tips": ["Pflegetipp 1", "Pflegetipp 2", "Pflegetipp 3"]
}
}
Regeln:
- scientificName: Botanischer Name (z.B. "Monstera deliciosa")
- commonNames: Array mit deutschen Namen
- confidence: Zahl von 0-100 (wie sicher bist du bei der Identifikation)
- health.status: "healthy" | "minor_issues" | "needs_care" | "critical"
- health.issues: Array mit erkannten Problemen (leer wenn gesund)
- care.light: "low" | "medium" | "bright" | "direct"
- care.wateringFrequencyDays: Anzahl Tage zwischen Gießvorgängen
- care.humidity: "low" | "medium" | "high"
- care.tips: 3-5 praktische Pflegetipps auf Deutsch
Falls du die Pflanze nicht identifizieren kannst, setze confidence auf 0 und scientificName auf "Unbekannt".`;
@Injectable()
export class VisionService {
private readonly logger = new Logger(VisionService.name);
private genAI: GoogleGenerativeAI | null = null;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('GOOGLE_GEMINI_API_KEY');
if (apiKey) {
this.genAI = new GoogleGenerativeAI(apiKey);
this.logger.log('Gemini Vision AI initialized');
} else {
this.logger.warn('GOOGLE_GEMINI_API_KEY not configured - Vision analysis disabled');
}
}
async analyzePlantImage(imageBuffer: Buffer, mimeType: string): Promise<AnalysisResult | null> {
if (!this.genAI) {
this.logger.error('Gemini AI not configured');
return null;
}
try {
const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const imagePart = {
inlineData: {
data: imageBuffer.toString('base64'),
mimeType: mimeType,
},
};
const result = await model.generateContent([PLANT_ANALYSIS_PROMPT, imagePart]);
const response = result.response.text().trim();
this.logger.debug(`Gemini raw response: ${response}`);
// Parse JSON response - handle potential markdown code blocks
let jsonStr = response;
if (response.includes('```')) {
const match = response.match(/```(?:json)?\s*([\s\S]*?)```/);
if (match) {
jsonStr = match[1].trim();
}
}
const parsed = JSON.parse(jsonStr) as AnalysisResult;
// Validate and sanitize response
this.validateAnalysisResult(parsed);
this.logger.log(
`Plant identified: ${parsed.identification.scientificName} (${parsed.identification.confidence}% confidence)`
);
return parsed;
} catch (error) {
this.logger.error(`Vision analysis failed: ${error}`);
return null;
}
}
private validateAnalysisResult(result: AnalysisResult): void {
// Validate identification
if (!result.identification) {
result.identification = {
scientificName: 'Unbekannt',
commonNames: [],
confidence: 0,
};
}
// Ensure confidence is within range
if (typeof result.identification.confidence !== 'number') {
result.identification.confidence = 0;
}
result.identification.confidence = Math.max(0, Math.min(100, result.identification.confidence));
// Validate health
if (!result.health) {
result.health = {
status: 'healthy',
issues: [],
details: '',
};
}
const validHealthStatuses = ['healthy', 'minor_issues', 'needs_care', 'critical'];
if (!validHealthStatuses.includes(result.health.status)) {
result.health.status = 'healthy';
}
// Validate care
if (!result.care) {
result.care = {
light: 'medium',
wateringFrequencyDays: 7,
humidity: 'medium',
temperature: '18-24°C',
soilType: 'Blumenerde',
tips: [],
};
}
const validLightLevels = ['low', 'medium', 'bright', 'direct'];
if (!validLightLevels.includes(result.care.light)) {
result.care.light = 'medium';
}
const validHumidityLevels = ['low', 'medium', 'high'];
if (!validHumidityLevels.includes(result.care.humidity)) {
result.care.humidity = 'medium';
}
if (
typeof result.care.wateringFrequencyDays !== 'number' ||
result.care.wateringFrequencyDays < 1
) {
result.care.wateringFrequencyDays = 7;
}
}
}

View file

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { PlantModule } from './plant/plant.module';
import { PhotoModule } from './photo/photo.module';
import { AnalysisModule } from './analysis/analysis.module';
import { WateringModule } from './watering/watering.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
PlantModule,
PhotoModule,
AnalysisModule,
WateringModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,29 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,4 @@
export * from './plants.schema';
export * from './plant-photos.schema';
export * from './plant-analyses.schema';
export * from './watering.schema';

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,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'planta-backend',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,38 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for web app
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5191',
'http://localhost:3001',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3022;
await app.listen(port);
console.log(`Planta backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,57 @@
import {
Controller,
Get,
Post,
Delete,
Put,
Param,
Query,
UseGuards,
UseInterceptors,
UploadedFile,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { PhotoService } from './photo.service';
@Controller('photos')
@UseGuards(JwtAuthGuard)
export class PhotoController {
constructor(private readonly photoService: PhotoService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
async upload(
@CurrentUser() user: CurrentUserData,
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 10 * 1024 * 1024 }), // 10MB
new FileTypeValidator({ fileType: /(jpg|jpeg|png|webp|heic)$/i }),
],
})
)
file: Express.Multer.File,
@Query('plantId') plantId?: string
) {
return this.photoService.upload(user.userId, file, plantId);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.photoService.findOne(id, user.userId);
}
@Delete(':id')
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.photoService.delete(id, user.userId);
}
@Put(':id/primary')
async setPrimary(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.photoService.setPrimary(id, user.userId);
}
}

View file

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { PhotoController } from './photo.controller';
import { PhotoService } from './photo.service';
import { StorageService } from './storage.service';
@Module({
imports: [
MulterModule.register({
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
},
}),
],
controllers: [PhotoController],
providers: [PhotoService, StorageService],
exports: [PhotoService, StorageService],
})
export class PhotoModule {}

View file

@ -0,0 +1,114 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
import { plantPhotos } from '../db/schema';
import { StorageService } from './storage.service';
@Injectable()
export class PhotoService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private storageService: StorageService
) {}
async upload(userId: string, file: Express.Multer.File, plantId?: string) {
// Upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadPhoto(userId, file);
// Create photo record
const [photo] = await this.db
.insert(plantPhotos)
.values({
userId,
plantId: plantId || null,
storagePath,
publicUrl,
filename: file.originalname,
mimeType: file.mimetype,
fileSize: file.size,
isPrimary: false,
isAnalyzed: false,
})
.returning();
// If this is the first photo for the plant, set it as primary
if (plantId) {
const existingPhotos = await this.db
.select()
.from(plantPhotos)
.where(eq(plantPhotos.plantId, plantId));
if (existingPhotos.length === 1) {
await this.db
.update(plantPhotos)
.set({ isPrimary: true })
.where(eq(plantPhotos.id, photo.id));
photo.isPrimary = true;
}
}
return photo;
}
async findOne(id: string, userId: string) {
const [photo] = await this.db
.select()
.from(plantPhotos)
.where(and(eq(plantPhotos.id, id), eq(plantPhotos.userId, userId)))
.limit(1);
if (!photo) {
throw new NotFoundException('Photo not found');
}
return photo;
}
async delete(id: string, userId: string) {
const photo = await this.findOne(id, userId);
// Delete from storage
await this.storageService.deletePhoto(photo.storagePath);
// Delete from database
await this.db.delete(plantPhotos).where(eq(plantPhotos.id, id));
return { success: true };
}
async setPrimary(id: string, userId: string) {
const photo = await this.findOne(id, userId);
if (!photo.plantId) {
throw new NotFoundException('Photo is not associated with a plant');
}
// Remove primary from all photos of this plant
await this.db
.update(plantPhotos)
.set({ isPrimary: false })
.where(eq(plantPhotos.plantId, photo.plantId));
// Set this photo as primary
await this.db.update(plantPhotos).set({ isPrimary: true }).where(eq(plantPhotos.id, id));
return { success: true };
}
async linkToPlant(photoId: string, plantId: string, userId: string) {
const photo = await this.findOne(photoId, userId);
await this.db.update(plantPhotos).set({ plantId }).where(eq(plantPhotos.id, photo.id));
return { success: true };
}
async markAnalyzed(photoId: string) {
await this.db.update(plantPhotos).set({ isAnalyzed: true }).where(eq(plantPhotos.id, photoId));
}
async getPhotoBuffer(storagePath: string): Promise<Buffer> {
return this.storageService.downloadPhoto(storagePath);
}
}

View file

@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createStorageClient, StorageClient } from '@manacore/shared-storage';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class StorageService {
private storage: StorageClient;
constructor(private configService: ConfigService) {
const publicUrl = this.configService.get<string>('PLANTA_S3_PUBLIC_URL');
this.storage = createStorageClient(
{
name: 'planta-storage',
publicUrl,
},
{
endpoint: this.configService.get<string>('S3_ENDPOINT'),
region: this.configService.get<string>('S3_REGION'),
accessKeyId: this.configService.get<string>('S3_ACCESS_KEY'),
secretAccessKey: this.configService.get<string>('S3_SECRET_KEY'),
}
);
}
async uploadPhoto(
userId: string,
file: Express.Multer.File
): Promise<{ storagePath: string; publicUrl: string }> {
const extension = file.originalname.split('.').pop() || 'jpg';
const filename = `${uuidv4()}.${extension}`;
const storagePath = `users/${userId}/photos/${filename}`;
await this.storage.upload(storagePath, file.buffer, {
contentType: file.mimetype,
public: true,
});
const publicUrl = this.storage.getPublicUrl(storagePath) ?? '';
return { storagePath, publicUrl };
}
async deletePhoto(storagePath: string): Promise<void> {
await this.storage.delete(storagePath);
}
async getPhotoUrl(storagePath: string): Promise<string> {
return this.storage.getPublicUrl(storagePath) ?? '';
}
async downloadPhoto(storagePath: string): Promise<Buffer> {
return this.storage.download(storagePath);
}
}

View file

@ -0,0 +1,18 @@
import { IsString, IsOptional, IsDateString } from 'class-validator';
export class CreatePlantDto {
@IsString()
name: string;
@IsOptional()
@IsString()
scientificName?: string;
@IsOptional()
@IsString()
commonName?: string;
@IsOptional()
@IsDateString()
acquiredAt?: string;
}

View file

@ -0,0 +1,35 @@
import { IsString, IsOptional, IsBoolean, IsNumber, IsIn } from 'class-validator';
export class UpdatePlantDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
scientificName?: string;
@IsOptional()
@IsString()
commonName?: string;
@IsOptional()
@IsString()
careNotes?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsIn(['low', 'medium', 'bright', 'direct'])
lightRequirements?: string;
@IsOptional()
@IsNumber()
wateringFrequencyDays?: number;
@IsOptional()
@IsIn(['low', 'medium', 'high'])
humidity?: string;
}

View file

@ -0,0 +1,40 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { PlantService } from './plant.service';
import { CreatePlantDto } from './dto/create-plant.dto';
import { UpdatePlantDto } from './dto/update-plant.dto';
@Controller('plants')
@UseGuards(JwtAuthGuard)
export class PlantController {
constructor(private readonly plantService: PlantService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
return this.plantService.findAll(user.userId);
}
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.plantService.findOne(id, user.userId);
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePlantDto) {
return this.plantService.create(user.userId, dto);
}
@Put(':id')
async update(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdatePlantDto
) {
return this.plantService.update(id, user.userId, dto);
}
@Delete(':id')
async delete(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
return this.plantService.delete(id, user.userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PlantController } from './plant.controller';
import { PlantService } from './plant.service';
@Module({
controllers: [PlantController],
providers: [PlantService],
exports: [PlantService],
})
export class PlantModule {}

View file

@ -0,0 +1,163 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
import { plants, plantPhotos, wateringSchedules, plantAnalyses } from '../db/schema';
import { CreatePlantDto } from './dto/create-plant.dto';
import { UpdatePlantDto } from './dto/update-plant.dto';
@Injectable()
export class PlantService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findAll(userId: string) {
const userPlants = await this.db
.select()
.from(plants)
.where(and(eq(plants.userId, userId), eq(plants.isActive, true)))
.orderBy(desc(plants.createdAt));
// Get primary photos for each plant
const plantsWithPhotos = await Promise.all(
userPlants.map(async (plant) => {
const photos = await this.db
.select()
.from(plantPhotos)
.where(and(eq(plantPhotos.plantId, plant.id), eq(plantPhotos.isPrimary, true)))
.limit(1);
const schedule = await this.db
.select()
.from(wateringSchedules)
.where(eq(wateringSchedules.plantId, plant.id))
.limit(1);
return {
...plant,
primaryPhoto: photos[0] || null,
wateringSchedule: schedule[0] || null,
};
})
);
return plantsWithPhotos;
}
async findOne(id: string, userId: string) {
const [plant] = await this.db
.select()
.from(plants)
.where(and(eq(plants.id, id), eq(plants.userId, userId)))
.limit(1);
if (!plant) {
throw new NotFoundException('Plant not found');
}
// Get all photos
const photos = await this.db
.select()
.from(plantPhotos)
.where(eq(plantPhotos.plantId, id))
.orderBy(desc(plantPhotos.isPrimary), desc(plantPhotos.createdAt));
// Get watering schedule
const [schedule] = await this.db
.select()
.from(wateringSchedules)
.where(eq(wateringSchedules.plantId, id))
.limit(1);
// Get latest analysis
const [latestAnalysis] = await this.db
.select()
.from(plantAnalyses)
.where(eq(plantAnalyses.plantId, id))
.orderBy(desc(plantAnalyses.createdAt))
.limit(1);
return {
...plant,
photos,
wateringSchedule: schedule || null,
latestAnalysis: latestAnalysis || null,
};
}
async create(userId: string, dto: CreatePlantDto) {
const [plant] = await this.db
.insert(plants)
.values({
userId,
name: dto.name,
scientificName: dto.scientificName,
commonName: dto.commonName,
acquiredAt: dto.acquiredAt ? new Date(dto.acquiredAt) : null,
})
.returning();
return plant;
}
async update(id: string, userId: string, dto: UpdatePlantDto) {
const [existing] = await this.db
.select()
.from(plants)
.where(and(eq(plants.id, id), eq(plants.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException('Plant not found');
}
const [updated] = await this.db
.update(plants)
.set({
...dto,
updatedAt: new Date(),
})
.where(eq(plants.id, id))
.returning();
return updated;
}
async delete(id: string, userId: string) {
const [existing] = await this.db
.select()
.from(plants)
.where(and(eq(plants.id, id), eq(plants.userId, userId)))
.limit(1);
if (!existing) {
throw new NotFoundException('Plant not found');
}
await this.db.delete(plants).where(eq(plants.id, id));
return { success: true };
}
async updateFromAnalysis(
plantId: string,
analysis: {
scientificName?: string;
commonName?: string;
lightRequirements?: string;
wateringFrequencyDays?: number;
humidity?: string;
temperature?: string;
soilType?: string;
careNotes?: string;
healthStatus?: string;
}
) {
await this.db
.update(plants)
.set({
...analysis,
updatedAt: new Date(),
})
.where(eq(plants.id, plantId));
}
}

View file

@ -0,0 +1,50 @@
import { Controller, Get, Post, Put, Param, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { WateringService } from './watering.service';
import { IsOptional, IsString, IsNumber, Min } from 'class-validator';
class LogWateringDto {
@IsOptional()
@IsString()
notes?: string;
}
class UpdateScheduleDto {
@IsNumber()
@Min(1)
frequencyDays: number;
}
@Controller('watering')
@UseGuards(JwtAuthGuard)
export class WateringController {
constructor(private readonly wateringService: WateringService) {}
@Get('upcoming')
async getUpcoming(@CurrentUser() user: CurrentUserData) {
return this.wateringService.getUpcoming(user.userId);
}
@Post(':plantId/water')
async logWatering(
@Param('plantId') plantId: string,
@CurrentUser() user: CurrentUserData,
@Body() dto: LogWateringDto
) {
return this.wateringService.logWatering(plantId, user.userId, dto.notes);
}
@Put(':plantId')
async updateSchedule(
@Param('plantId') plantId: string,
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateScheduleDto
) {
return this.wateringService.updateSchedule(plantId, user.userId, dto.frequencyDays);
}
@Get(':plantId/history')
async getHistory(@Param('plantId') plantId: string, @CurrentUser() user: CurrentUserData) {
return this.wateringService.getWateringHistory(plantId, user.userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WateringController } from './watering.controller';
import { WateringService } from './watering.service';
@Module({
controllers: [WateringController],
providers: [WateringService],
exports: [WateringService],
})
export class WateringModule {}

View file

@ -0,0 +1,169 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, lte, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import type { Database } from '../db/connection';
import { wateringSchedules, wateringLogs, plants } from '../db/schema';
@Injectable()
export class WateringService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async getUpcoming(userId: string) {
const now = new Date();
const threeDaysFromNow = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000);
const schedules = await this.db
.select({
schedule: wateringSchedules,
plant: plants,
})
.from(wateringSchedules)
.innerJoin(plants, eq(wateringSchedules.plantId, plants.id))
.where(
and(
eq(wateringSchedules.userId, userId),
lte(wateringSchedules.nextWateringAt, threeDaysFromNow)
)
)
.orderBy(wateringSchedules.nextWateringAt);
return schedules.map(({ schedule, plant }) => {
const nextWatering = schedule.nextWateringAt ? new Date(schedule.nextWateringAt) : null;
const daysUntil = nextWatering
? Math.ceil((nextWatering.getTime() - now.getTime()) / (24 * 60 * 60 * 1000))
: 0;
return {
plantId: plant.id,
plantName: plant.name,
daysUntilWatering: daysUntil,
isOverdue: daysUntil < 0,
lastWateredAt: schedule.lastWateredAt,
nextWateringAt: schedule.nextWateringAt,
};
});
}
async logWatering(plantId: string, userId: string, notes?: string) {
// Verify plant ownership
const [plant] = await this.db
.select()
.from(plants)
.where(and(eq(plants.id, plantId), eq(plants.userId, userId)))
.limit(1);
if (!plant) {
throw new NotFoundException('Plant not found');
}
const now = new Date();
// Create watering log
const [log] = await this.db
.insert(wateringLogs)
.values({
plantId,
userId,
wateredAt: now,
notes,
})
.returning();
// Update or create watering schedule
const [existingSchedule] = await this.db
.select()
.from(wateringSchedules)
.where(eq(wateringSchedules.plantId, plantId))
.limit(1);
const frequencyDays = existingSchedule?.frequencyDays || plant.wateringFrequencyDays || 7;
const nextWateringAt = new Date(now.getTime() + frequencyDays * 24 * 60 * 60 * 1000);
if (existingSchedule) {
await this.db
.update(wateringSchedules)
.set({
lastWateredAt: now,
nextWateringAt,
updatedAt: now,
})
.where(eq(wateringSchedules.id, existingSchedule.id));
} else {
await this.db.insert(wateringSchedules).values({
plantId,
userId,
frequencyDays,
lastWateredAt: now,
nextWateringAt,
});
}
return {
success: true,
log,
nextWateringAt,
};
}
async updateSchedule(plantId: string, userId: string, frequencyDays: number) {
// Verify plant ownership
const [plant] = await this.db
.select()
.from(plants)
.where(and(eq(plants.id, plantId), eq(plants.userId, userId)))
.limit(1);
if (!plant) {
throw new NotFoundException('Plant not found');
}
const [existingSchedule] = await this.db
.select()
.from(wateringSchedules)
.where(eq(wateringSchedules.plantId, plantId))
.limit(1);
const now = new Date();
if (existingSchedule) {
const nextWateringAt = existingSchedule.lastWateredAt
? new Date(
new Date(existingSchedule.lastWateredAt).getTime() + frequencyDays * 24 * 60 * 60 * 1000
)
: new Date(now.getTime() + frequencyDays * 24 * 60 * 60 * 1000);
await this.db
.update(wateringSchedules)
.set({
frequencyDays,
nextWateringAt,
updatedAt: now,
})
.where(eq(wateringSchedules.id, existingSchedule.id));
return { success: true, nextWateringAt };
} else {
const nextWateringAt = new Date(now.getTime() + frequencyDays * 24 * 60 * 60 * 1000);
await this.db.insert(wateringSchedules).values({
plantId,
userId,
frequencyDays,
nextWateringAt,
});
return { success: true, nextWateringAt };
}
}
async getWateringHistory(plantId: string, userId: string) {
const logs = await this.db
.select()
.from(wateringLogs)
.where(and(eq(wateringLogs.plantId, plantId), eq(wateringLogs.userId, userId)))
.orderBy(desc(wateringLogs.wateredAt))
.limit(50);
return logs;
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,43 @@
{
"name": "@planta/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@planta/shared": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,190 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared-ui/src";
@source "../../../packages/shared-theme-ui/src";
@source "../../../packages/shared-theme-ui/src/components";
/* Planta-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Planta primary color (green) */
--planta-primary: 142 76% 36%;
}
}
/* Utility Classes */
@layer components {
/* Card styles */
.card {
background-color: hsl(var(--card));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--border));
transition: transform var(--transition-base), box-shadow var(--transition-base);
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
/* Plant card specific */
.plant-card {
position: relative;
overflow: hidden;
aspect-ratio: 1;
}
.plant-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Button styles */
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--radius-md);
font-weight: 500;
transition: all var(--transition-base);
cursor: pointer;
border: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--primary) / 0.9);
}
.btn-success {
background: hsl(142 76% 36%);
color: white;
}
.btn-success:hover {
background: hsl(142 76% 30%);
}
/* Input styles */
.input {
padding: var(--spacing-sm) var(--spacing-md);
border: 2px solid hsl(var(--border));
border-radius: var(--radius-md);
background-color: hsl(var(--background));
color: hsl(var(--foreground));
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: hsl(var(--primary));
}
/* Watering status indicators */
.water-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.water-status.overdue {
color: hsl(0 84% 60%);
}
.water-status.soon {
color: hsl(38 92% 50%);
}
.water-status.ok {
color: hsl(142 76% 36%);
}
/* Upload zone */
.upload-zone {
border: 2px dashed hsl(var(--border));
border-radius: var(--radius-lg);
padding: var(--spacing-2xl);
text-align: center;
cursor: pointer;
transition: all var(--transition-base);
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.05);
}
/* Health status badge */
.health-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
}
.health-badge.healthy {
background: hsl(142 76% 36% / 0.1);
color: hsl(142 76% 36%);
}
.health-badge.needs_attention {
background: hsl(38 92% 50% / 0.1);
color: hsl(38 92% 40%);
}
.health-badge.sick {
background: hsl(0 84% 60% / 0.1);
color: hsl(0 84% 50%);
}
}
/* Legacy color variable compatibility */
@layer base {
:root {
--color-primary: var(--primary);
--color-background: var(--background);
--color-surface: var(--card);
--color-text-primary: var(--foreground);
--color-text-secondary: var(--muted-foreground);
--color-border: var(--border);
--color-success: 142 76% 36%;
--color-warning: 38 92% 50%;
--color-error: 0 84% 60%;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Planta - Pflanzendokumentation</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,30 @@
/**
* Analysis API
*/
import { fetchApi } from './client';
import type { PlantAnalysis } from '@planta/shared';
export const analysisApi = {
async analyze(photoId: string, plantId?: string): Promise<PlantAnalysis | null> {
const { data, error } = await fetchApi<PlantAnalysis>('/analysis/identify', {
method: 'POST',
body: { photoId, plantId },
});
if (error) {
console.error('Failed to analyze photo:', error);
return null;
}
return data;
},
async getByPhotoId(photoId: string): Promise<PlantAnalysis | null> {
const { data, error } = await fetchApi<PlantAnalysis>(`/analysis/${photoId}`);
if (error) {
console.error('Failed to fetch analysis:', error);
return null;
}
return data;
},
};

View file

@ -0,0 +1,62 @@
/**
* API Client for Planta backend
*/
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3022';
}
return 'http://localhost:3022';
}
export async function fetchApi<T>(
endpoint: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
body?: unknown;
formData?: FormData;
} = {}
): Promise<{ data: T | null; error: string | null }> {
const token = await authStore.getValidToken();
if (!token) {
return { data: null, error: 'Not authenticated' };
}
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
// Don't set Content-Type for FormData - browser will set it with boundary
if (!options.formData) {
headers['Content-Type'] = 'application/json';
}
try {
const response = await fetch(`${getBackendUrl()}/api/v1${endpoint}`, {
method: options.method || 'GET',
headers,
body: options.formData || (options.body ? JSON.stringify(options.body) : undefined),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: errorData.message || `API error: ${response.status}`,
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -0,0 +1,36 @@
/**
* Photos API
*/
import { fetchApi } from './client';
import type { PlantPhoto } from '@planta/shared';
export const photosApi = {
async upload(file: File, plantId?: string): Promise<PlantPhoto | null> {
const formData = new FormData();
formData.append('file', file);
const endpoint = plantId ? `/photos/upload?plantId=${plantId}` : '/photos/upload';
const { data, error } = await fetchApi<PlantPhoto>(endpoint, {
method: 'POST',
formData,
});
if (error) {
console.error('Failed to upload photo:', error);
return null;
}
return data;
},
async delete(id: string): Promise<boolean> {
const { error } = await fetchApi(`/photos/${id}`, { method: 'DELETE' });
return !error;
},
async setPrimary(id: string): Promise<boolean> {
const { error } = await fetchApi(`/photos/${id}/primary`, { method: 'PUT' });
return !error;
},
};

View file

@ -0,0 +1,55 @@
/**
* Plants API
*/
import { fetchApi } from './client';
import type { Plant, PlantWithDetails, CreatePlantDto, UpdatePlantDto } from '@planta/shared';
export const plantsApi = {
async getAll(): Promise<Plant[]> {
const { data, error } = await fetchApi<Plant[]>('/plants');
if (error) {
console.error('Failed to fetch plants:', error);
return [];
}
return data || [];
},
async getById(id: string): Promise<PlantWithDetails | null> {
const { data, error } = await fetchApi<PlantWithDetails>(`/plants/${id}`);
if (error) {
console.error('Failed to fetch plant:', error);
return null;
}
return data;
},
async create(dto: CreatePlantDto): Promise<Plant | null> {
const { data, error } = await fetchApi<Plant>('/plants', {
method: 'POST',
body: dto,
});
if (error) {
console.error('Failed to create plant:', error);
return null;
}
return data;
},
async update(id: string, dto: UpdatePlantDto): Promise<Plant | null> {
const { data, error } = await fetchApi<Plant>(`/plants/${id}`, {
method: 'PUT',
body: dto,
});
if (error) {
console.error('Failed to update plant:', error);
return null;
}
return data;
},
async delete(id: string): Promise<boolean> {
const { error } = await fetchApi(`/plants/${id}`, { method: 'DELETE' });
return !error;
},
};

View file

@ -0,0 +1,42 @@
/**
* Watering API
*/
import { fetchApi } from './client';
import type { WateringStatus, WateringLog } from '@planta/shared';
export const wateringApi = {
async getUpcoming(): Promise<WateringStatus[]> {
const { data, error } = await fetchApi<WateringStatus[]>('/watering/upcoming');
if (error) {
console.error('Failed to fetch upcoming watering:', error);
return [];
}
return data || [];
},
async logWatering(plantId: string, notes?: string): Promise<boolean> {
const { error } = await fetchApi(`/watering/${plantId}/water`, {
method: 'POST',
body: { notes },
});
return !error;
},
async updateSchedule(plantId: string, frequencyDays: number): Promise<boolean> {
const { error } = await fetchApi(`/watering/${plantId}`, {
method: 'PUT',
body: { frequencyDays },
});
return !error;
},
async getHistory(plantId: string): Promise<WateringLog[]> {
const { data, error } = await fetchApi<WateringLog[]>(`/watering/${plantId}/history`);
if (error) {
console.error('Failed to fetch watering history:', error);
return [];
}
return data || [];
},
};

View file

@ -0,0 +1,169 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Get auth URL dynamically at runtime
function getAuthUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
.__PUBLIC_MANA_CORE_AUTH_URL__;
return injectedUrl || 'http://localhost:3001';
}
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3022';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3022';
}
// Lazy initialization to avoid SSR issues
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(),
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
function getTokenManager() {
if (!browser) return null;
getAuthService();
return _tokenManager;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
user = null;
}
},
async getValidToken(): Promise<string | null> {
const tokenManager = getTokenManager();
if (!tokenManager) {
return null;
}
return await tokenManager.getValidToken();
},
};

View file

@ -0,0 +1,7 @@
/**
* Theme Store - Simple theme management using @manacore/shared-theme
*/
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore('planta');

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
// Navigation items for Planta
const navItems: PillNavItem[] = [
{ href: '/dashboard', label: 'Meine Pflanzen', icon: 'document' },
{ href: '/add', label: 'Hinzufügen', icon: 'plus' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
let isDark = $derived(theme.isDark);
function handleToggleTheme() {
theme.toggleMode();
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
onMount(() => {
if (!authStore.isAuthenticated) {
goto('/login');
}
});
</script>
{#if authStore.isAuthenticated}
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Planta"
homeRoute="/dashboard"
onToggleTheme={handleToggleTheme}
{isDark}
showLogout={true}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#10b981"
/>
<main class="main-content pt-24">
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{:else}
<div class="flex min-h-screen items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
</style>

View file

@ -0,0 +1,276 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { photosApi } from '$lib/api/photos';
import { analysisApi } from '$lib/api/analysis';
import { plantsApi } from '$lib/api/plants';
import type { PlantPhoto, PlantAnalysis } from '@planta/shared';
let step = $state<'upload' | 'analyzing' | 'result'>('upload');
let dragover = $state(false);
let photo = $state<PlantPhoto | null>(null);
let analysis = $state<PlantAnalysis | null>(null);
let plantName = $state('');
let error = $state('');
let saving = $state(false);
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragover = true;
}
function handleDragLeave() {
dragover = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
dragover = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
await uploadFile(files[0]);
}
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
await uploadFile(input.files[0]);
}
}
async function uploadFile(file: File) {
if (!file.type.startsWith('image/')) {
error = 'Bitte wähle ein Bild aus';
return;
}
error = '';
step = 'analyzing';
// Upload photo
const uploadedPhoto = await photosApi.upload(file);
if (!uploadedPhoto) {
error = 'Foto konnte nicht hochgeladen werden';
step = 'upload';
return;
}
photo = uploadedPhoto;
// Analyze with AI
const analysisResult = await analysisApi.analyze(uploadedPhoto.id);
if (!analysisResult) {
error = 'Analyse fehlgeschlagen. Bitte versuche es erneut.';
step = 'upload';
return;
}
analysis = analysisResult;
// Set default plant name from analysis
if (analysisResult.commonNames && analysisResult.commonNames.length > 0) {
plantName = analysisResult.commonNames[0];
} else if (analysisResult.scientificName) {
plantName = analysisResult.scientificName;
}
step = 'result';
}
async function savePlant() {
if (!plantName.trim()) {
error = 'Bitte gib einen Namen für die Pflanze ein';
return;
}
if (!photo || !analysis) {
error = 'Keine Analyse vorhanden';
return;
}
saving = true;
error = '';
// Create plant
const plant = await plantsApi.create({
name: plantName.trim(),
scientificName: analysis.scientificName || undefined,
commonName: analysis.commonNames?.[0] || undefined,
});
if (!plant) {
error = 'Pflanze konnte nicht gespeichert werden';
saving = false;
return;
}
// Navigate to plant detail
goto(`/plants/${plant.id}`);
}
function getHealthBadgeClass(status: string | null | undefined): string {
if (!status) return 'healthy';
if (status === 'minor_issues' || status === 'needs_care') return 'needs_attention';
if (status === 'critical') return 'sick';
return 'healthy';
}
function getHealthText(status: string | null | undefined): string {
const map: Record<string, string> = {
healthy: 'Gesund',
minor_issues: 'Kleine Probleme',
needs_care: 'Braucht Pflege',
critical: 'Kritisch',
};
return map[status || ''] || 'Gesund';
}
</script>
<svelte:head>
<title>Pflanze hinzufügen - Planta</title>
</svelte:head>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">Pflanze hinzufügen</h1>
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
{#if step === 'upload'}
<div
class="upload-zone"
class:dragover
role="button"
tabindex="0"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => document.getElementById('file-input')?.click()}
onkeydown={(e) => e.key === 'Enter' && document.getElementById('file-input')?.click()}
>
<div class="text-6xl mb-4">📷</div>
<p class="text-lg font-medium">Foto hochladen</p>
<p class="text-sm text-muted-foreground mt-1">
Ziehe ein Bild hierher oder klicke zum Auswählen
</p>
<input
id="file-input"
type="file"
accept="image/*"
class="hidden"
onchange={handleFileSelect}
/>
</div>
<p class="text-center text-sm text-muted-foreground">
Die KI analysiert dein Foto und erstellt automatisch einen Pflanzensteckbrief.
</p>
{:else if step === 'analyzing'}
<div class="text-center py-12">
<div
class="h-16 w-16 mx-auto mb-4 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-lg font-medium">Pflanze wird analysiert...</p>
<p class="text-sm text-muted-foreground mt-1">
Die KI identifiziert deine Pflanze und erstellt Pflegeempfehlungen.
</p>
</div>
{:else if step === 'result' && analysis}
<div class="card">
{#if photo?.publicUrl}
<img
src={photo.publicUrl}
alt="Pflanzenfoto"
class="w-full h-64 object-cover rounded-lg mb-4"
/>
{/if}
<div class="space-y-4">
<!-- Identification -->
<div>
<h3 class="font-semibold text-lg">
{analysis.scientificName || 'Unbekannte Pflanze'}
</h3>
{#if analysis.commonNames && analysis.commonNames.length > 0}
<p class="text-muted-foreground">{analysis.commonNames.join(', ')}</p>
{/if}
{#if analysis.confidence}
<p class="text-sm text-muted-foreground mt-1">
Sicherheit: {analysis.confidence}%
</p>
{/if}
</div>
<!-- Health Status -->
<div>
<span class="health-badge {getHealthBadgeClass(analysis.healthAssessment)}">
{getHealthText(analysis.healthAssessment)}
</span>
{#if analysis.healthDetails}
<p class="text-sm text-muted-foreground mt-2">{analysis.healthDetails}</p>
{/if}
</div>
<!-- Care Recommendations -->
<div class="grid grid-cols-2 gap-4">
{#if analysis.lightAdvice}
<div class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium">☀️ Licht</p>
<p class="text-sm text-muted-foreground">{analysis.lightAdvice}</p>
</div>
{/if}
{#if analysis.wateringAdvice}
<div class="p-3 bg-muted rounded-lg">
<p class="text-sm font-medium">💧 Gießen</p>
<p class="text-sm text-muted-foreground">{analysis.wateringAdvice}</p>
</div>
{/if}
</div>
<!-- Care Tips -->
{#if analysis.generalTips && analysis.generalTips.length > 0}
<div>
<p class="text-sm font-medium mb-2">Pflegetipps</p>
<ul class="list-disc list-inside text-sm text-muted-foreground space-y-1">
{#each analysis.generalTips as tip}
<li>{tip}</li>
{/each}
</ul>
</div>
{/if}
<!-- Save Form -->
<div class="border-t pt-4 mt-4">
<label for="plant-name" class="block text-sm font-medium mb-2">
Name deiner Pflanze
</label>
<input
id="plant-name"
type="text"
bind:value={plantName}
class="input w-full"
placeholder="z.B. Meine Monstera"
/>
<button
type="button"
class="btn btn-success w-full mt-4"
onclick={savePlant}
disabled={saving}
>
{#if saving}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
Pflanze speichern
{/if}
</button>
</div>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,133 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { plantsApi } from '$lib/api/plants';
import { wateringApi } from '$lib/api/watering';
import type { Plant, WateringStatus } from '@planta/shared';
let plants = $state<Plant[]>([]);
let wateringStatus = $state<WateringStatus[]>([]);
let loading = $state(true);
onMount(async () => {
const [plantsData, wateringData] = await Promise.all([
plantsApi.getAll(),
wateringApi.getUpcoming(),
]);
plants = plantsData;
wateringStatus = wateringData;
loading = false;
});
function getWateringStatusForPlant(plantId: string): WateringStatus | undefined {
return wateringStatus.find((s) => s.plantId === plantId);
}
function getWateringClass(status: WateringStatus | undefined): string {
if (!status) return '';
if (status.isOverdue) return 'overdue';
if (status.daysUntilWatering <= 1) return 'soon';
return 'ok';
}
function getWateringText(status: WateringStatus | undefined): string {
if (!status) return '';
if (status.isOverdue) return 'Überfällig!';
if (status.daysUntilWatering === 0) return 'Heute gießen';
if (status.daysUntilWatering === 1) return 'Morgen gießen';
return `In ${status.daysUntilWatering} Tagen`;
}
async function handleWater(plantId: string, e: Event) {
e.stopPropagation();
const success = await wateringApi.logWatering(plantId);
if (success) {
// Refresh watering status
wateringStatus = await wateringApi.getUpcoming();
}
}
</script>
<svelte:head>
<title>Meine Pflanzen - Planta</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Meine Pflanzen</h1>
<a href="/add" class="btn btn-success"> + Pflanze hinzufügen </a>
</div>
{#if loading}
<div class="flex justify-center py-12">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>
{:else if plants.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">🌱</div>
<h2 class="text-xl font-semibold mb-2">Noch keine Pflanzen</h2>
<p class="text-muted-foreground mb-4">
Füge deine erste Pflanze hinzu und lass sie von der KI analysieren.
</p>
<a href="/add" class="btn btn-success"> Erste Pflanze hinzufügen </a>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each plants as plant (plant.id)}
{@const status = getWateringStatusForPlant(plant.id)}
<button
type="button"
class="card plant-card cursor-pointer text-left"
onclick={() => goto(`/plants/${plant.id}`)}
>
{#if plant.primaryPhoto?.publicUrl}
<img src={plant.primaryPhoto.publicUrl} alt={plant.name} />
{:else}
<div class="flex h-full w-full items-center justify-center bg-muted text-4xl">🌿</div>
{/if}
<div
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3"
>
<h3 class="font-semibold text-white truncate">{plant.name}</h3>
{#if plant.commonName}
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
{/if}
{#if status}
<div class="water-status {getWateringClass(status)} mt-1">
<span>💧</span>
<span>{getWateringText(status)}</span>
</div>
{/if}
</div>
{#if status && (status.isOverdue || status.daysUntilWatering <= 1)}
<button
type="button"
class="absolute top-2 right-2 rounded-full bg-blue-500 p-2 text-white hover:bg-blue-600"
onclick={(e) => handleWater(plant.id, e)}
title="Als gegossen markieren"
>
💧
</button>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.plant-card {
position: relative;
overflow: hidden;
aspect-ratio: 1;
padding: 0;
}
.plant-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View file

@ -0,0 +1,228 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { plantsApi } from '$lib/api/plants';
import { wateringApi } from '$lib/api/watering';
import type { PlantWithDetails, WateringLog } from '@planta/shared';
let plant = $state<PlantWithDetails | null>(null);
let wateringHistory = $state<WateringLog[]>([]);
let loading = $state(true);
let watering = $state(false);
$effect(() => {
const plantId = $page.params.id;
if (plantId) {
loadPlant(plantId);
}
});
async function loadPlant(id: string) {
loading = true;
const [plantData, historyData] = await Promise.all([
plantsApi.getById(id),
wateringApi.getHistory(id),
]);
plant = plantData;
wateringHistory = historyData;
loading = false;
}
async function handleWater() {
if (!plant) return;
watering = true;
const success = await wateringApi.logWatering(plant.id);
if (success) {
// Reload plant data
await loadPlant(plant.id);
}
watering = false;
}
async function handleDelete() {
if (!plant) return;
if (!confirm(`Möchtest du "${plant.name}" wirklich löschen?`)) return;
const success = await plantsApi.delete(plant.id);
if (success) {
goto('/dashboard');
}
}
function formatDate(date: Date | string | undefined | null): string {
if (!date) return '-';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function getHealthBadgeClass(status: string | null | undefined): string {
if (!status) return 'healthy';
if (status === 'needs_attention') return 'needs_attention';
if (status === 'sick') return 'sick';
return 'healthy';
}
function getHealthText(status: string | null | undefined): string {
const map: Record<string, string> = {
healthy: 'Gesund',
needs_attention: 'Braucht Aufmerksamkeit',
sick: 'Krank',
};
return map[status || ''] || 'Gesund';
}
function getLightText(light: string | null | undefined): string {
const map: Record<string, string> = {
low: 'Wenig Licht',
medium: 'Mittleres Licht',
bright: 'Helles Licht',
direct: 'Direkte Sonne',
};
return map[light || ''] || '-';
}
function getHumidityText(humidity: string | null | undefined): string {
const map: Record<string, string> = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
};
return map[humidity || ''] || '-';
}
</script>
<svelte:head>
<title>{plant?.name || 'Pflanze'} - Planta</title>
</svelte:head>
{#if loading}
<div class="flex justify-center py-12">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>
{:else if !plant}
<div class="text-center py-12">
<p class="text-lg">Pflanze nicht gefunden</p>
<a href="/dashboard" class="btn btn-primary mt-4">Zurück zur Übersicht</a>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold">{plant.name}</h1>
{#if plant.scientificName}
<p class="text-muted-foreground italic">{plant.scientificName}</p>
{/if}
{#if plant.commonName && plant.commonName !== plant.name}
<p class="text-muted-foreground">{plant.commonName}</p>
{/if}
</div>
<span class="health-badge {getHealthBadgeClass(plant.healthStatus)}">
{getHealthText(plant.healthStatus)}
</span>
</div>
<!-- Photo Gallery -->
{#if plant.photos && plant.photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each plant.photos as photo (photo.id)}
<img
src={photo.publicUrl}
alt={plant.name}
class="w-full aspect-square object-cover rounded-lg"
class:ring-2={photo.isPrimary}
class:ring-primary={photo.isPrimary}
/>
{/each}
</div>
{/if}
<!-- Care Info -->
<div class="card">
<h2 class="font-semibold mb-4">Pflege</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-muted-foreground">Licht</p>
<p class="font-medium">☀️ {getLightText(plant.lightRequirements)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Gießen</p>
<p class="font-medium">
💧 {plant.wateringFrequencyDays ? `Alle ${plant.wateringFrequencyDays} Tage` : '-'}
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Luftfeuchtigkeit</p>
<p class="font-medium">💨 {getHumidityText(plant.humidity)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Temperatur</p>
<p class="font-medium">🌡️ {plant.temperature || '-'}</p>
</div>
</div>
{#if plant.careNotes}
<div class="mt-4 pt-4 border-t">
<p class="text-sm text-muted-foreground mb-1">Pflegehinweise</p>
<p class="text-sm whitespace-pre-line">{plant.careNotes}</p>
</div>
{/if}
</div>
<!-- Watering Schedule -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold">Gießplan</h2>
<button type="button" class="btn btn-success" onclick={handleWater} disabled={watering}>
{#if watering}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
💧 Jetzt gießen
{/if}
</button>
</div>
{#if plant.wateringSchedule}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm text-muted-foreground">Zuletzt gegossen</p>
<p class="font-medium">{formatDate(plant.wateringSchedule.lastWateredAt)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Nächstes Gießen</p>
<p class="font-medium">{formatDate(plant.wateringSchedule.nextWateringAt)}</p>
</div>
</div>
{/if}
{#if wateringHistory.length > 0}
<div class="border-t pt-4">
<p class="text-sm text-muted-foreground mb-2">Letzte Gießvorgänge</p>
<ul class="space-y-1">
{#each wateringHistory.slice(0, 5) as log (log.id)}
<li class="text-sm flex justify-between">
<span>💧 Gegossen</span>
<span class="text-muted-foreground">{formatDate(log.wateredAt)}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-4">
<a href="/dashboard" class="btn flex-1 bg-muted text-foreground"> ← Zurück </a>
<button type="button" class="btn bg-destructive text-white" onclick={handleDelete}>
Löschen
</button>
</div>
</div>
{/if}

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let isDark = $derived(theme.isDark);
</script>
<svelte:head>
<title>Einstellungen - Planta</title>
</svelte:head>
<div class="max-w-xl space-y-6">
<h1 class="text-2xl font-bold">Einstellungen</h1>
<!-- Theme -->
<div class="card">
<h2 class="font-semibold mb-4">Darstellung</h2>
<div class="flex items-center justify-between">
<div>
<p class="font-medium">Dunkles Design</p>
<p class="text-sm text-muted-foreground">Wechsle zwischen hellem und dunklem Modus</p>
</div>
<button
type="button"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
class:bg-primary={isDark}
class:bg-muted={!isDark}
onclick={() => theme.toggleMode()}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
class:translate-x-6={isDark}
class:translate-x-1={!isDark}
></span>
</button>
</div>
</div>
<!-- Account -->
<div class="card">
<h2 class="font-semibold mb-4">Konto</h2>
<div class="space-y-4">
<div>
<p class="text-sm text-muted-foreground">E-Mail</p>
<p class="font-medium">{authStore.user?.email || '-'}</p>
</div>
</div>
</div>
<!-- About -->
<div class="card">
<h2 class="font-semibold mb-4">Über Planta</h2>
<p class="text-sm text-muted-foreground">
Planta hilft dir, deine Pflanzen zu dokumentieren und zu pflegen. Mache ein Foto und die KI
erstellt automatisch einen Steckbrief mit Pflegehinweisen und Gießvorschlägen.
</p>
<p class="text-sm text-muted-foreground mt-2">Version 1.0.0</p>
</div>
</div>

View file

@ -0,0 +1,13 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-screen flex-col items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-foreground">Planta</h1>
<p class="mt-2 text-muted-foreground">Deine Pflanzen dokumentieren</p>
</div>
{@render children()}
</div>
</div>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
loading = true;
const result = await authStore.signIn(email, password);
if (result.success) {
goto('/dashboard');
} else {
error = result.error || 'Login fehlgeschlagen';
}
loading = false;
}
</script>
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-foreground">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="input mt-1 w-full"
placeholder="deine@email.de"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-foreground">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
class="input mt-1 w-full"
placeholder="••••••••"
/>
</div>
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
{#if loading}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
Anmelden
{/if}
</button>
<p class="text-center text-sm text-muted-foreground">
Noch kein Konto?
<a href="/register" class="text-primary hover:underline">Registrieren</a>
</p>
</form>

View file

@ -0,0 +1,103 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
let email = $state('');
let password = $state('');
let passwordConfirm = $state('');
let error = $state('');
let loading = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
if (password !== passwordConfirm) {
error = 'Passwörter stimmen nicht überein';
return;
}
if (password.length < 8) {
error = 'Passwort muss mindestens 8 Zeichen lang sein';
return;
}
loading = true;
const result = await authStore.signUp(email, password);
if (result.success) {
if (result.needsVerification) {
error = 'Bitte bestätige deine E-Mail-Adresse';
} else {
goto('/dashboard');
}
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
loading = false;
}
</script>
<form onsubmit={handleSubmit} class="space-y-4">
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-foreground">E-Mail</label>
<input
id="email"
type="email"
bind:value={email}
required
class="input mt-1 w-full"
placeholder="deine@email.de"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-foreground">Passwort</label>
<input
id="password"
type="password"
bind:value={password}
required
minlength="8"
class="input mt-1 w-full"
placeholder="Mindestens 8 Zeichen"
/>
</div>
<div>
<label for="passwordConfirm" class="block text-sm font-medium text-foreground">
Passwort bestätigen
</label>
<input
id="passwordConfirm"
type="password"
bind:value={passwordConfirm}
required
class="input mt-1 w-full"
placeholder="Passwort wiederholen"
/>
</div>
<button type="submit" class="btn btn-primary w-full" disabled={loading}>
{#if loading}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
Registrieren
{/if}
</button>
<p class="text-center text-sm text-muted-foreground">
Bereits ein Konto?
<a href="/login" class="text-primary hover:underline">Anmelden</a>
</p>
</form>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.initialize();
loading = false;
});
</script>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
onMount(() => {
if (authStore.isAuthenticated) {
goto('/dashboard');
} else {
goto('/login');
}
});
</script>
<div class="flex min-h-screen items-center justify-center">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};
export default config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

@ -0,0 +1,37 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5191,
strictPort: true,
},
ssr: {
noExternal: [
'@planta/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
],
},
optimizeDeps: {
exclude: [
'@planta/shared',
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
],
},
});

18
apps/planta/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "planta",
"version": "1.0.0",
"private": true,
"description": "Planta - Plant Documentation & Care App",
"scripts": {
"dev": "turbo run dev",
"dev:backend": "pnpm --filter @planta/backend dev",
"dev:web": "pnpm --filter @planta/web dev",
"db:push": "pnpm --filter @planta/backend db:push",
"db:studio": "pnpm --filter @planta/backend db:studio",
"db:seed": "pnpm --filter @planta/backend db:seed"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.15.0"
}

View file

@ -0,0 +1,18 @@
{
"name": "@planta/shared",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "~5.9.2"
},
"dependencies": {}
}

View file

@ -0,0 +1 @@
export * from './types/index.js';

View file

@ -0,0 +1,159 @@
// Light level requirements
export type LightLevel = 'low' | 'medium' | 'bright' | 'direct';
// Humidity requirements
export type HumidityLevel = 'low' | 'medium' | 'high';
// Health status
export type HealthStatus = 'healthy' | 'needs_attention' | 'sick';
// Health assessment from AI
export type HealthAssessment = 'healthy' | 'minor_issues' | 'needs_care' | 'critical';
// Plant entity
export interface Plant {
id: string;
userId: string;
name: string;
scientificName?: string;
commonName?: string;
species?: string;
lightRequirements?: LightLevel;
wateringFrequencyDays?: number;
humidity?: HumidityLevel;
temperature?: string;
soilType?: string;
careNotes?: string;
isActive: boolean;
healthStatus?: HealthStatus;
acquiredAt?: Date;
createdAt: Date;
updatedAt: Date;
}
// Plant with all related data
export interface PlantWithDetails extends Plant {
photos: PlantPhoto[];
wateringSchedule?: WateringSchedule;
latestAnalysis?: PlantAnalysis;
}
// Plant photo
export interface PlantPhoto {
id: string;
plantId: string;
userId: string;
storagePath: string;
publicUrl?: string;
filename: string;
mimeType?: string;
fileSize?: number;
width?: number;
height?: number;
isPrimary: boolean;
isAnalyzed: boolean;
takenAt?: Date;
createdAt: Date;
}
// AI analysis result
export interface PlantAnalysis {
id: string;
photoId: string;
plantId?: string;
userId: string;
identifiedSpecies?: string;
scientificName?: string;
commonNames?: string[];
confidence?: number;
healthAssessment?: HealthAssessment;
healthDetails?: string;
issues?: string[];
wateringAdvice?: string;
lightAdvice?: string;
fertilizingAdvice?: string;
generalTips?: string[];
model?: string;
tokensUsed?: number;
createdAt: Date;
}
// Watering schedule
export interface WateringSchedule {
id: string;
plantId: string;
userId: string;
frequencyDays: number;
lastWateredAt?: Date;
nextWateringAt?: Date;
reminderEnabled: boolean;
reminderHoursBefore: number;
createdAt: Date;
updatedAt: Date;
}
// Watering log entry
export interface WateringLog {
id: string;
plantId: string;
userId: string;
wateredAt: Date;
notes?: string;
createdAt: Date;
}
// Watering status for dashboard
export interface WateringStatus {
plantId: string;
plantName: string;
daysUntilWatering: number;
isOverdue: boolean;
lastWateredAt?: Date;
nextWateringAt?: Date;
}
// DTOs for API
export interface CreatePlantDto {
name: string;
scientificName?: string;
commonName?: string;
acquiredAt?: string;
}
export interface UpdatePlantDto {
name?: string;
scientificName?: string;
commonName?: string;
careNotes?: string;
isActive?: boolean;
lightRequirements?: LightLevel;
wateringFrequencyDays?: number;
humidity?: HumidityLevel;
}
export interface AnalysisRequest {
photoId: string;
plantId?: string;
}
// AI analysis response structure
export interface AnalysisResult {
identification: {
scientificName: string;
commonNames: string[];
confidence: number;
};
health: {
status: HealthAssessment;
issues: string[];
details: string;
};
care: {
light: LightLevel;
wateringFrequencyDays: number;
humidity: HumidityLevel;
temperature: string;
soilType: string;
tips: string[];
};
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -85,7 +85,9 @@ services:
mc mb --ignore-existing myminio/contacts-storage;
mc mb --ignore-existing myminio/storage-storage;
mc mb --ignore-existing myminio/inventory-storage;
mc mb --ignore-existing myminio/planta-storage;
mc anonymous set download myminio/picture-storage;
mc anonymous set download myminio/planta-storage;
mc anonymous set download myminio/inventory-storage;
echo 'Buckets created successfully';
exit 0;

View file

@ -146,6 +146,14 @@
"dev:worldream:web": "pnpm --filter @worldream/web dev",
"context:dev": "turbo run dev --filter=context...",
"dev:context:mobile": "pnpm --filter @context/mobile dev",
"planta:dev": "turbo run dev --filter=planta...",
"dev:planta:web": "pnpm --filter @planta/web dev",
"dev:planta:backend": "pnpm --filter @planta/backend dev",
"dev:planta:app": "turbo run dev --filter=@planta/web --filter=@planta/backend",
"dev:planta:full": "./scripts/setup-databases.sh planta && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:planta:backend\" \"pnpm dev:planta:web\"",
"planta:db:push": "pnpm --filter @planta/backend db:push",
"planta:db:studio": "pnpm --filter @planta/backend db:studio",
"planta:db:seed": "pnpm --filter @planta/backend db:seed",
"docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
"docker:up:infra": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init",
"docker:up:db": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",

211
pnpm-lock.yaml generated
View file

@ -2541,6 +2541,206 @@ importers:
specifier: ~5.8.3
version: 5.8.3
apps/planta:
devDependencies:
typescript:
specifier: ^5.9.3
version: 5.9.3
apps/planta/apps/backend:
dependencies:
'@google/generative-ai':
specifier: ^0.21.0
version: 0.21.0
'@manacore/shared-nestjs-auth':
specifier: workspace:*
version: link:../../../../packages/shared-nestjs-auth
'@manacore/shared-storage':
specifier: workspace:*
version: link:../../../../packages/shared-storage
'@nestjs/common':
specifier: ^10.4.15
version: 10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/config':
specifier: ^3.3.0
version: 3.3.0(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.15
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.15
version: 10.4.20(@nestjs/common@10.4.20(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.20)
'@planta/shared':
specifier: workspace:*
version: link:../../packages/shared
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.14.1
version: 0.14.3
dotenv:
specifier: ^16.4.7
version: 16.6.1
drizzle-kit:
specifier: ^0.30.2
version: 0.30.6
drizzle-orm:
specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.0)(@types/react@19.2.7)(expo-sqlite@15.2.14(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(kysely@0.28.8)(postgres@3.4.7)(react@19.1.0)
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
postgres:
specifier: ^3.4.5
version: 3.4.7
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.1
version: 7.8.2
uuid:
specifier: ^11.0.3
version: 11.0.3
devDependencies:
'@nestjs/cli':
specifier: ^10.4.9
version: 10.4.9(esbuild@0.27.0)
'@nestjs/schematics':
specifier: ^10.2.3
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
'@types/express':
specifier: ^5.0.0
version: 5.0.5
'@types/multer':
specifier: ^1.4.12
version: 1.4.13
'@types/node':
specifier: ^22.10.2
version: 22.19.1
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
'@typescript-eslint/eslint-plugin':
specifier: ^8.18.1
version: 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^8.18.1
version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
eslint:
specifier: ^9.17.0
version: 9.39.1(jiti@2.6.1)
eslint-config-prettier:
specifier: ^9.1.0
version: 9.1.2(eslint@9.39.1(jiti@2.6.1))
eslint-plugin-prettier:
specifier: ^5.2.1
version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2)
prettier:
specifier: ^3.4.2
version: 3.6.2
source-map-support:
specifier: ^0.5.21
version: 0.5.21
ts-loader:
specifier: ^9.5.1
version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0))
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3)
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
tsx:
specifier: ^4.19.2
version: 4.20.6
typescript:
specifier: ^5.7.2
version: 5.9.3
apps/planta/apps/web:
dependencies:
'@manacore/shared-auth':
specifier: workspace:*
version: link:../../../../packages/shared-auth
'@manacore/shared-auth-ui':
specifier: workspace:*
version: link:../../../../packages/shared-auth-ui
'@manacore/shared-branding':
specifier: workspace:*
version: link:../../../../packages/shared-branding
'@manacore/shared-i18n':
specifier: workspace:*
version: link:../../../../packages/shared-i18n
'@manacore/shared-icons':
specifier: workspace:*
version: link:../../../../packages/shared-icons
'@manacore/shared-tailwind':
specifier: workspace:*
version: link:../../../../packages/shared-tailwind
'@manacore/shared-theme':
specifier: workspace:*
version: link:../../../../packages/shared-theme
'@manacore/shared-theme-ui':
specifier: workspace:*
version: link:../../../../packages/shared-theme-ui
'@manacore/shared-ui':
specifier: workspace:*
version: link:../../../../packages/shared-ui
'@planta/shared':
specifier: workspace:*
version: link:../../packages/shared
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.44.0)
devDependencies:
'@sveltejs/adapter-auto':
specifier: ^3.0.0
version: 3.3.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))
'@sveltejs/kit':
specifier: ^2.0.0
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@sveltejs/vite-plugin-svelte':
specifier: ^5.0.0
version: 5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.1.17(vite@6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@types/node':
specifier: ^20.0.0
version: 20.19.25
prettier:
specifier: ^3.1.1
version: 3.6.2
prettier-plugin-svelte:
specifier: ^3.1.2
version: 3.4.0(prettier@3.6.2)(svelte@5.44.0)
svelte:
specifier: ^5.0.0
version: 5.44.0
svelte-check:
specifier: ^4.0.0
version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3)
tailwindcss:
specifier: ^4.1.7
version: 4.1.17
tslib:
specifier: ^2.4.1
version: 2.8.1
typescript:
specifier: ^5.0.0
version: 5.9.3
vite:
specifier: ^6.0.0
version: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
apps/planta/packages/shared:
devDependencies:
typescript:
specifier: ~5.9.2
version: 5.9.3
apps/todo:
devDependencies:
typescript:
@ -7072,6 +7272,10 @@ packages:
'@modelcontextprotocol/sdk':
optional: true
'@google/generative-ai@0.21.0':
resolution: {integrity: sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==}
engines: {node: '>=18.0.0'}
'@google/generative-ai@0.24.1':
resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==}
engines: {node: '>=18.0.0'}
@ -9869,6 +10073,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@ -23440,6 +23647,8 @@ snapshots:
- supports-color
- utf-8-validate
'@google/generative-ai@0.21.0': {}
'@google/generative-ai@0.24.1': {}
'@gorhom/bottom-sheet@5.2.7(@types/react@19.2.7)(react-native-gesture-handler@2.28.0(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.0(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.5)(@types/react@19.2.7)(react@19.1.0))(react@19.1.0)':
@ -27881,6 +28090,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/uuid@10.0.0': {}
'@types/validator@13.15.10': {}
'@types/webxr@0.5.24': {}

View file

@ -71,6 +71,7 @@ ALL_DATABASES=(
"techbase"
"voxel_lava"
"figgos"
"planta"
)
# Check if specific service requested
@ -136,9 +137,13 @@ setup_service() {
create_db_if_not_exists "figgos"
push_schema "@figgos/backend" "figgos"
;;
planta)
create_db_if_not_exists "planta"
push_schema "@planta/backend" "planta"
;;
*)
echo -e "${RED}Unknown service: $service${NC}"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos"
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta"
exit 1
;;
esac
@ -162,7 +167,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
echo "--------------------------------------"
# Push schemas for all known services
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos; do
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta; do
setup_service "$service" 2>/dev/null || true
done