diff --git a/.env.example b/.env.example index 666788e..9c2b10c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,17 @@ # LocalGreenChain Environment Variables +# =========================================== +# DATABASE CONFIGURATION (Required for Agent 2) +# =========================================== + +# PostgreSQL connection string +# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA +DATABASE_URL="postgresql://postgres:password@localhost:5432/localgreenchain?schema=public" + +# =========================================== +# EXTERNAL SERVICES +# =========================================== + # Plants.net API (optional) PLANTS_NET_API_KEY=your_api_key_here diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..31f7ed3 --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,363 @@ +# LocalGreenChain Database Integration + +This document describes the PostgreSQL database integration for LocalGreenChain, implemented as part of Agent 2's deployment. + +## Overview + +The database layer provides persistent storage for all LocalGreenChain entities using PostgreSQL and Prisma ORM. This replaces the previous file-based JSON storage with a robust, scalable database solution. + +## Quick Start + +### 1. Install Dependencies + +```bash +bun install +``` + +### 2. Configure Database + +Copy the environment template and configure your database connection: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your PostgreSQL connection string: + +```env +DATABASE_URL="postgresql://user:password@localhost:5432/localgreenchain?schema=public" +``` + +### 3. Generate Prisma Client + +```bash +bun run db:generate +``` + +### 4. Run Migrations + +For development: +```bash +bun run db:migrate +``` + +For production: +```bash +bun run db:migrate:prod +``` + +### 5. Seed Database (Optional) + +```bash +bun run db:seed +``` + +## Database Schema + +The schema includes the following main entities: + +### Core Entities + +| Entity | Description | +|--------|-------------| +| `User` | User accounts with authentication and profiles | +| `Plant` | Plant records with lineage tracking | +| `TransportEvent` | Supply chain transport events | +| `SeedBatch` | Seed batch tracking | +| `HarvestBatch` | Harvest batch records | + +### Vertical Farming + +| Entity | Description | +|--------|-------------| +| `VerticalFarm` | Vertical farm facilities | +| `GrowingZone` | Individual growing zones | +| `CropBatch` | Active crop batches | +| `GrowingRecipe` | Growing recipes/protocols | +| `ResourceUsage` | Energy and resource tracking | +| `FarmAnalytics` | Farm performance analytics | + +### Demand & Market + +| Entity | Description | +|--------|-------------| +| `ConsumerPreference` | Consumer food preferences | +| `DemandSignal` | Aggregated demand signals | +| `SupplyCommitment` | Grower supply commitments | +| `MarketMatch` | Matched supply and demand | +| `SeasonalPlan` | Grower seasonal plans | +| `DemandForecast` | Demand predictions | +| `PlantingRecommendation` | Planting recommendations | + +### Audit & Blockchain + +| Entity | Description | +|--------|-------------| +| `AuditLog` | System audit trail | +| `BlockchainBlock` | Blockchain block storage | + +## Usage + +### Importing the Database Layer + +```typescript +import * as db from '@/lib/db'; +// or import specific functions +import { createPlant, getPlantById, getPlantLineage } from '@/lib/db'; +``` + +### Common Operations + +#### Users + +```typescript +// Create a user +const user = await db.createUser({ + email: 'grower@example.com', + name: 'John Farmer', + userType: 'GROWER', + city: 'San Francisco', + country: 'USA', +}); + +// Get user by email +const user = await db.getUserByEmail('grower@example.com'); + +// Get user with their plants +const userWithPlants = await db.getUserWithPlants(userId); +``` + +#### Plants + +```typescript +// Create a plant +const plant = await db.createPlant({ + commonName: 'Cherry Tomato', + scientificName: 'Solanum lycopersicum', + plantedDate: new Date(), + latitude: 37.7749, + longitude: -122.4194, + ownerId: userId, +}); + +// Clone a plant +const clone = await db.clonePlant(parentId, newOwnerId, 'CLONE'); + +// Get plant lineage +const lineage = await db.getPlantLineage(plantId); +// Returns: { plant, ancestors, descendants, siblings } + +// Find nearby plants +const nearby = await db.getNearbyPlants({ + latitude: 37.7749, + longitude: -122.4194, + radiusKm: 10, +}); +``` + +#### Transport Events + +```typescript +// Create a transport event +const event = await db.createTransportEvent({ + eventType: 'DISTRIBUTION', + fromLatitude: 37.8044, + fromLongitude: -122.2712, + fromLocationType: 'VERTICAL_FARM', + toLatitude: 37.7849, + toLongitude: -122.4094, + toLocationType: 'MARKET', + durationMinutes: 25, + transportMethod: 'ELECTRIC_TRUCK', + senderId: farmerId, + receiverId: distributorId, +}); + +// Get plant journey +const journey = await db.getPlantJourney(plantId); + +// Get environmental impact +const impact = await db.getEnvironmentalImpact({ userId }); +``` + +#### Vertical Farms + +```typescript +// Create a farm +const farm = await db.createVerticalFarm({ + name: 'Urban Greens', + ownerId: userId, + latitude: 37.8044, + longitude: -122.2712, + address: '123 Farm St', + city: 'Oakland', + country: 'USA', + specs: { totalAreaSqm: 500, numberOfLevels: 5 }, +}); + +// Create a growing zone +const zone = await db.createGrowingZone({ + name: 'Zone A', + farmId: farm.id, + level: 1, + areaSqm: 80, + growingMethod: 'NFT', + plantPositions: 400, +}); + +// Create a crop batch +const batch = await db.createCropBatch({ + farmId: farm.id, + zoneId: zone.id, + cropType: 'Lettuce', + plantCount: 400, + plantingDate: new Date(), + expectedHarvestDate: new Date(Date.now() + 28 * 24 * 60 * 60 * 1000), + expectedYieldKg: 60, +}); +``` + +#### Demand & Market + +```typescript +// Set consumer preferences +await db.upsertConsumerPreference({ + consumerId: userId, + latitude: 37.7849, + longitude: -122.4094, + preferredCategories: ['leafy_greens', 'herbs'], + certificationPreferences: ['organic', 'local'], +}); + +// Create supply commitment +const commitment = await db.createSupplyCommitment({ + growerId: farmerId, + produceType: 'Butterhead Lettuce', + committedQuantityKg: 60, + availableFrom: new Date(), + availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + pricePerKg: 8.5, + deliveryRadiusKm: 25, + deliveryMethods: ['grower_delivery', 'customer_pickup'], +}); + +// Find matching supply +const matches = await db.findMatchingSupply( + 'Lettuce', + { latitude: 37.7849, longitude: -122.4094, radiusKm: 20 }, + new Date() +); +``` + +### Using the Database-Backed Blockchain + +The database layer includes a `PlantChainDB` class that provides blockchain functionality with PostgreSQL persistence: + +```typescript +import { getBlockchainDB } from '@/lib/blockchain/manager'; + +// Get the database-backed blockchain +const chain = await getBlockchainDB(); + +// Register a plant (creates both DB record and blockchain block) +const { plant, block } = await chain.registerPlant({ + commonName: 'Tomato', + latitude: 37.7749, + longitude: -122.4194, + ownerId: userId, +}); + +// Clone a plant +const { plant: clone, block: cloneBlock } = await chain.clonePlant( + parentId, + newOwnerId, + 'CLONE' +); + +// Verify blockchain integrity +const isValid = await chain.isChainValid(); +``` + +## NPM Scripts + +| Script | Description | +|--------|-------------| +| `bun run db:generate` | Generate Prisma client | +| `bun run db:push` | Push schema to database (dev) | +| `bun run db:migrate` | Create and run migration (dev) | +| `bun run db:migrate:prod` | Run migrations (production) | +| `bun run db:seed` | Seed database with test data | +| `bun run db:studio` | Open Prisma Studio GUI | + +## Architecture + +``` +lib/db/ +├── prisma.ts # Prisma client singleton +├── types.ts # Type definitions and utilities +├── users.ts # User CRUD operations +├── plants.ts # Plant operations with lineage +├── transport.ts # Transport event operations +├── farms.ts # Vertical farm operations +├── demand.ts # Demand and market operations +├── audit.ts # Audit logging and blockchain +└── index.ts # Central exports + +prisma/ +├── schema.prisma # Database schema +└── seed.ts # Seed script +``` + +## Migration from File Storage + +If you have existing data in JSON files, you can migrate to the database: + +1. Ensure database is configured and migrations are run +2. Load existing JSON data +3. Use the database service layer to insert records +4. Verify data integrity +5. Remove old JSON files + +## Performance Considerations + +- The schema includes strategic indexes on frequently queried fields +- Pagination is supported for large result sets +- Location-based queries use in-memory filtering (consider PostGIS for large scale) +- Blockchain integrity verification scans all blocks (cache results for performance) + +## Troubleshooting + +### Connection Issues + +```bash +# Test database connection +bunx prisma db pull +``` + +### Migration Issues + +```bash +# Reset database (WARNING: deletes all data) +bunx prisma migrate reset + +# Generate new migration +bunx prisma migrate dev --name your_migration_name +``` + +### Type Issues + +```bash +# Regenerate Prisma client +bun run db:generate +``` + +## Security Notes + +- Never commit `.env` files with real credentials +- Use environment variables for all sensitive configuration +- Database user should have minimal required permissions +- Enable SSL for production database connections + +--- + +*Implemented by Agent 2 - Database Integration* diff --git a/lib/blockchain/PlantChainDB.ts b/lib/blockchain/PlantChainDB.ts new file mode 100644 index 0000000..01f641c --- /dev/null +++ b/lib/blockchain/PlantChainDB.ts @@ -0,0 +1,419 @@ +/** + * PlantChainDB - Database-backed blockchain for plant lineage tracking + * This implementation uses PostgreSQL via Prisma for persistence while + * maintaining blockchain data integrity guarantees + */ + +import { PlantBlock } from './PlantBlock'; +import type { PlantData, PlantLineage, NearbyPlant, PlantNetwork } from './types'; +import * as db from '../db'; +import type { Plant, BlockchainBlock } from '../db/types'; + +/** + * Convert database Plant model to PlantData format + */ +function plantToPlantData(plant: Plant & { owner?: { id: string; name: string; email: string } }): PlantData { + return { + id: plant.id, + commonName: plant.commonName, + scientificName: plant.scientificName || undefined, + species: plant.species || undefined, + genus: plant.genus || undefined, + family: plant.family || undefined, + parentPlantId: plant.parentPlantId || undefined, + propagationType: plant.propagationType.toLowerCase() as PlantData['propagationType'], + generation: plant.generation, + plantedDate: plant.plantedDate.toISOString(), + harvestedDate: plant.harvestedDate?.toISOString(), + status: plant.status.toLowerCase() as PlantData['status'], + location: { + latitude: plant.latitude, + longitude: plant.longitude, + address: plant.address || undefined, + city: plant.city || undefined, + country: plant.country || undefined, + }, + owner: plant.owner ? { + id: plant.owner.id, + name: plant.owner.name, + email: plant.owner.email, + } : { + id: plant.ownerId, + name: 'Unknown', + email: 'unknown@localgreenchain.io', + }, + childPlants: [], // Will be populated separately if needed + environment: plant.environment as PlantData['environment'], + growthMetrics: plant.growthMetrics as PlantData['growthMetrics'], + notes: plant.notes || undefined, + images: plant.images || undefined, + plantsNetId: plant.plantsNetId || undefined, + registeredAt: plant.registeredAt.toISOString(), + updatedAt: plant.updatedAt.toISOString(), + }; +} + +/** + * PlantChainDB - Database-backed plant blockchain + */ +export class PlantChainDB { + public difficulty: number; + + constructor(difficulty: number = 4) { + this.difficulty = difficulty; + } + + /** + * Initialize the chain with genesis block if needed + */ + async initialize(): Promise { + const latestBlock = await db.getLatestBlockchainBlock(); + if (!latestBlock) { + await this.createGenesisBlock(); + } + } + + /** + * Create the genesis block + */ + private async createGenesisBlock(): Promise { + const genesisPlant: PlantData = { + id: 'genesis-plant-0', + commonName: 'Genesis Plant', + scientificName: 'Blockchain primordialis', + propagationType: 'original', + generation: 0, + plantedDate: new Date().toISOString(), + status: 'mature', + location: { + latitude: 0, + longitude: 0, + address: 'The Beginning', + }, + owner: { + id: 'system', + name: 'LocalGreenChain', + email: 'system@localgreenchain.org', + }, + childPlants: [], + registeredAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const block = new PlantBlock(0, new Date().toISOString(), genesisPlant, '0'); + + return db.createBlockchainBlock({ + index: 0, + timestamp: new Date(), + previousHash: '0', + hash: block.hash, + nonce: 0, + blockType: 'genesis', + content: { plant: genesisPlant }, + }); + } + + /** + * Get the latest block + */ + async getLatestBlock(): Promise { + return db.getLatestBlockchainBlock(); + } + + /** + * Register a new plant and add to blockchain + */ + async registerPlant(plantData: { + id?: string; + commonName: string; + scientificName?: string; + species?: string; + genus?: string; + family?: string; + plantedDate?: Date; + status?: 'SPROUTED' | 'GROWING' | 'MATURE' | 'FLOWERING' | 'FRUITING' | 'DORMANT' | 'DECEASED'; + latitude: number; + longitude: number; + address?: string; + city?: string; + country?: string; + ownerId: string; + notes?: string; + images?: string[]; + plantsNetId?: string; + }): Promise<{ plant: Plant; block: BlockchainBlock }> { + // Create plant in database + const plant = await db.createPlant({ + ...plantData, + plantedDate: plantData.plantedDate || new Date(), + propagationType: 'ORIGINAL', + generation: 0, + }); + + // Get latest block for previous hash + const latestBlock = await this.getLatestBlock(); + const previousHash = latestBlock?.hash || '0'; + const nextIndex = (latestBlock?.index ?? -1) + 1; + + // Create PlantBlock for mining + const plantDataForBlock = await this.getPlantData(plant.id); + const newBlock = new PlantBlock( + nextIndex, + new Date().toISOString(), + plantDataForBlock!, + previousHash + ); + newBlock.mineBlock(this.difficulty); + + // Store block in database + const blockchainBlock = await db.createBlockchainBlock({ + index: nextIndex, + timestamp: new Date(), + previousHash, + hash: newBlock.hash, + nonce: newBlock.nonce, + blockType: 'plant', + plantId: plant.id, + content: { plant: plantDataForBlock, action: 'REGISTER' }, + }); + + // Update plant with blockchain reference + await db.updatePlantBlockchain(plant.id, nextIndex, newBlock.hash); + + return { plant, block: blockchainBlock }; + } + + /** + * Clone a plant and add to blockchain + */ + async clonePlant( + parentPlantId: string, + ownerId: string, + propagationType: 'SEED' | 'CLONE' | 'CUTTING' | 'DIVISION' | 'GRAFTING', + overrides?: { + latitude?: number; + longitude?: number; + address?: string; + city?: string; + country?: string; + notes?: string; + } + ): Promise<{ plant: Plant; block: BlockchainBlock }> { + // Create cloned plant in database + const plant = await db.clonePlant( + parentPlantId, + ownerId, + propagationType, + overrides + ); + + // Get latest block for previous hash + const latestBlock = await this.getLatestBlock(); + const previousHash = latestBlock?.hash || '0'; + const nextIndex = (latestBlock?.index ?? -1) + 1; + + // Create PlantBlock for mining + const plantDataForBlock = await this.getPlantData(plant.id); + const newBlock = new PlantBlock( + nextIndex, + new Date().toISOString(), + plantDataForBlock!, + previousHash + ); + newBlock.mineBlock(this.difficulty); + + // Store block in database + const blockchainBlock = await db.createBlockchainBlock({ + index: nextIndex, + timestamp: new Date(), + previousHash, + hash: newBlock.hash, + nonce: newBlock.nonce, + blockType: 'plant', + plantId: plant.id, + content: { + plant: plantDataForBlock, + action: 'CLONE', + parentPlantId, + }, + }); + + // Update plant with blockchain reference + await db.updatePlantBlockchain(plant.id, nextIndex, newBlock.hash); + + return { plant, block: blockchainBlock }; + } + + /** + * Update plant status and add to blockchain + */ + async updatePlantStatus( + plantId: string, + status: 'SPROUTED' | 'GROWING' | 'MATURE' | 'FLOWERING' | 'FRUITING' | 'DORMANT' | 'DECEASED', + harvestedDate?: Date + ): Promise<{ plant: Plant; block: BlockchainBlock }> { + // Update plant in database + const plant = await db.updatePlantStatus(plantId, status, harvestedDate); + + // Get latest block for previous hash + const latestBlock = await this.getLatestBlock(); + const previousHash = latestBlock?.hash || '0'; + const nextIndex = (latestBlock?.index ?? -1) + 1; + + // Create PlantBlock for mining + const plantDataForBlock = await this.getPlantData(plant.id); + const newBlock = new PlantBlock( + nextIndex, + new Date().toISOString(), + plantDataForBlock!, + previousHash + ); + newBlock.mineBlock(this.difficulty); + + // Store block in database + const blockchainBlock = await db.createBlockchainBlock({ + index: nextIndex, + timestamp: new Date(), + previousHash, + hash: newBlock.hash, + nonce: newBlock.nonce, + blockType: 'plant', + plantId: plant.id, + content: { + plant: plantDataForBlock, + action: 'UPDATE_STATUS', + newStatus: status, + }, + }); + + // Update plant with blockchain reference + await db.updatePlantBlockchain(plant.id, nextIndex, newBlock.hash); + + return { plant, block: blockchainBlock }; + } + + /** + * Get plant data in PlantData format + */ + async getPlantData(plantId: string): Promise { + const plant = await db.getPlantWithOwner(plantId); + if (!plant) return null; + + const plantData = plantToPlantData(plant as Plant & { owner: { id: string; name: string; email: string } }); + + // Get child plant IDs + const lineage = await db.getPlantLineage(plantId); + plantData.childPlants = lineage.descendants.map(d => d.id); + + return plantData; + } + + /** + * Get a plant by ID + */ + async getPlant(plantId: string): Promise { + return db.getPlantById(plantId); + } + + /** + * Get plant with owner + */ + async getPlantWithOwner(plantId: string) { + return db.getPlantWithOwner(plantId); + } + + /** + * Get complete lineage for a plant + */ + async getPlantLineage(plantId: string): Promise { + const result = await db.getPlantLineage(plantId); + if (!result.plant) return null; + + const plant = result.plant as Plant & { owner: { id: string; name: string; email: string } }; + + return { + plantId, + ancestors: result.ancestors.map(p => plantToPlantData(p as Plant & { owner: { id: string; name: string; email: string } })), + descendants: result.descendants.map(p => plantToPlantData(p as Plant & { owner: { id: string; name: string; email: string } })), + siblings: result.siblings.map(p => plantToPlantData(p as Plant & { owner: { id: string; name: string; email: string } })), + generation: plant.generation, + }; + } + + /** + * Find plants near a location + */ + async findNearbyPlants( + latitude: number, + longitude: number, + radiusKm: number = 50, + excludeOwnerId?: string + ): Promise { + const plants = await db.getNearbyPlants( + { latitude, longitude, radiusKm }, + excludeOwnerId + ); + + return plants.map(p => ({ + plant: plantToPlantData(p as Plant & { owner: { id: string; name: string; email: string } }), + distance: p.distance, + owner: { + id: p.ownerId, + name: 'Unknown', + email: 'unknown@localgreenchain.io', + }, + })); + } + + /** + * Get network statistics + */ + async getNetworkStats(): Promise { + return db.getPlantNetworkStats(); + } + + /** + * Validate the blockchain integrity + */ + async isChainValid(): Promise { + const result = await db.verifyBlockchainIntegrity(); + return result.isValid; + } + + /** + * Get blockchain statistics + */ + async getBlockchainStats() { + return db.getBlockchainStats(); + } + + /** + * Search plants + */ + async searchPlants(query: string, options?: { page?: number; limit?: number }) { + return db.searchPlants(query, options); + } + + /** + * Get plants by owner + */ + async getPlantsByOwner(ownerId: string) { + return db.getPlantsByOwner(ownerId); + } +} + +// Singleton instance +let plantChainInstance: PlantChainDB | null = null; + +/** + * Get the singleton PlantChainDB instance + */ +export async function getPlantChain(): Promise { + if (!plantChainInstance) { + plantChainInstance = new PlantChainDB(); + await plantChainInstance.initialize(); + } + return plantChainInstance; +} + +export default PlantChainDB; diff --git a/lib/blockchain/manager.ts b/lib/blockchain/manager.ts index b0d2abc..b0e8546 100644 --- a/lib/blockchain/manager.ts +++ b/lib/blockchain/manager.ts @@ -1,14 +1,22 @@ /** * Blockchain Manager * Singleton to manage the global plant blockchain instance + * + * Supports two modes: + * 1. File-based (legacy): Uses JSON file storage + * 2. Database-backed: Uses PostgreSQL via Prisma (recommended) */ import { PlantChain } from './PlantChain'; +import { PlantChainDB, getPlantChain as getDBPlantChain } from './PlantChainDB'; import fs from 'fs'; import path from 'path'; const BLOCKCHAIN_FILE = path.join(process.cwd(), 'data', 'plantchain.json'); +// Flag to determine storage mode +const USE_DATABASE = process.env.DATABASE_URL ? true : false; + class BlockchainManager { private static instance: BlockchainManager; private plantChain: PlantChain; @@ -104,3 +112,21 @@ export function getBlockchain(): PlantChain { export function saveBlockchain(): void { BlockchainManager.getInstance().saveBlockchain(); } + +/** + * Get the database-backed blockchain instance + * Use this for production applications with PostgreSQL + */ +export async function getBlockchainDB(): Promise { + return getDBPlantChain(); +} + +/** + * Check if using database storage + */ +export function isUsingDatabase(): boolean { + return USE_DATABASE; +} + +// Export the DB class for type usage +export { PlantChainDB }; diff --git a/lib/db/audit.ts b/lib/db/audit.ts new file mode 100644 index 0000000..041312f --- /dev/null +++ b/lib/db/audit.ts @@ -0,0 +1,296 @@ +/** + * Audit & Blockchain Database Service + * Operations for audit logging and blockchain block storage + */ + +import prisma from './prisma'; +import type { AuditLog, BlockchainBlock, Prisma } from '@prisma/client'; +import type { PaginationOptions, PaginatedResult, DateRangeFilter } from './types'; +import { createPaginatedResult } from './types'; + +// ============================================ +// AUDIT LOG OPERATIONS +// ============================================ + +// Create an audit log entry +export async function createAuditLog(data: { + userId?: string; + action: string; + entityType: string; + entityId?: string; + previousValue?: Record; + newValue?: Record; + metadata?: Record; + ipAddress?: string; + userAgent?: string; +}): Promise { + return prisma.auditLog.create({ data }); +} + +// Get audit logs with pagination +export async function getAuditLogs( + options: PaginationOptions = {}, + filters?: { + userId?: string; + action?: string; + entityType?: string; + entityId?: string; + dateRange?: DateRangeFilter; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 50; + const skip = (page - 1) * limit; + + const where: Prisma.AuditLogWhereInput = {}; + if (filters?.userId) where.userId = filters.userId; + if (filters?.action) where.action = { contains: filters.action, mode: 'insensitive' }; + if (filters?.entityType) where.entityType = filters.entityType; + if (filters?.entityId) where.entityId = filters.entityId; + if (filters?.dateRange) { + where.timestamp = { + gte: filters.dateRange.start, + lte: filters.dateRange.end, + }; + } + + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ + where, + skip, + take: limit, + orderBy: { timestamp: 'desc' }, + include: { user: true }, + }), + prisma.auditLog.count({ where }), + ]); + + return createPaginatedResult(logs, total, page, limit); +} + +// Get audit logs for entity +export async function getEntityAuditLogs( + entityType: string, + entityId: string +): Promise { + return prisma.auditLog.findMany({ + where: { entityType, entityId }, + orderBy: { timestamp: 'desc' }, + include: { user: true }, + }); +} + +// Get audit logs by user +export async function getUserAuditLogs( + userId: string, + options: PaginationOptions = {} +): Promise> { + const page = options.page || 1; + const limit = options.limit || 50; + const skip = (page - 1) * limit; + + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ + where: { userId }, + skip, + take: limit, + orderBy: { timestamp: 'desc' }, + }), + prisma.auditLog.count({ where: { userId } }), + ]); + + return createPaginatedResult(logs, total, page, limit); +} + +// Get recent audit logs summary +export async function getAuditLogsSummary(hours: number = 24) { + const since = new Date(Date.now() - hours * 60 * 60 * 1000); + + const [ + totalLogs, + actionBreakdown, + entityBreakdown, + recentLogs, + ] = await Promise.all([ + prisma.auditLog.count({ + where: { timestamp: { gte: since } }, + }), + prisma.auditLog.groupBy({ + by: ['action'], + _count: { action: true }, + where: { timestamp: { gte: since } }, + }), + prisma.auditLog.groupBy({ + by: ['entityType'], + _count: { entityType: true }, + where: { timestamp: { gte: since } }, + }), + prisma.auditLog.findMany({ + where: { timestamp: { gte: since } }, + orderBy: { timestamp: 'desc' }, + take: 10, + include: { user: true }, + }), + ]); + + return { + totalLogs, + actionBreakdown: Object.fromEntries( + actionBreakdown.map(a => [a.action, a._count.action]) + ), + entityBreakdown: Object.fromEntries( + entityBreakdown.map(e => [e.entityType, e._count.entityType]) + ), + recentLogs, + }; +} + +// ============================================ +// BLOCKCHAIN BLOCK OPERATIONS +// ============================================ + +// Create a blockchain block +export async function createBlockchainBlock(data: { + index: number; + timestamp: Date; + previousHash: string; + hash: string; + nonce: number; + blockType: string; + plantId?: string; + transportEventId?: string; + content: Record; +}): Promise { + return prisma.blockchainBlock.create({ data }); +} + +// Get blockchain block by index +export async function getBlockchainBlockByIndex(index: number): Promise { + return prisma.blockchainBlock.findUnique({ + where: { index }, + }); +} + +// Get blockchain block by hash +export async function getBlockchainBlockByHash(hash: string): Promise { + return prisma.blockchainBlock.findUnique({ + where: { hash }, + }); +} + +// Get latest blockchain block +export async function getLatestBlockchainBlock(): Promise { + return prisma.blockchainBlock.findFirst({ + orderBy: { index: 'desc' }, + }); +} + +// Get blockchain blocks with pagination +export async function getBlockchainBlocks( + options: PaginationOptions = {}, + filters?: { + blockType?: string; + plantId?: string; + transportEventId?: string; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 50; + const skip = (page - 1) * limit; + + const where: Prisma.BlockchainBlockWhereInput = {}; + if (filters?.blockType) where.blockType = filters.blockType; + if (filters?.plantId) where.plantId = filters.plantId; + if (filters?.transportEventId) where.transportEventId = filters.transportEventId; + + const [blocks, total] = await Promise.all([ + prisma.blockchainBlock.findMany({ + where, + skip, + take: limit, + orderBy: { index: 'desc' }, + }), + prisma.blockchainBlock.count({ where }), + ]); + + return createPaginatedResult(blocks, total, page, limit); +} + +// Verify blockchain integrity +export async function verifyBlockchainIntegrity(): Promise<{ + isValid: boolean; + invalidBlocks: number[]; + totalBlocks: number; +}> { + const blocks = await prisma.blockchainBlock.findMany({ + orderBy: { index: 'asc' }, + }); + + const invalidBlocks: number[] = []; + + for (let i = 1; i < blocks.length; i++) { + const currentBlock = blocks[i]; + const previousBlock = blocks[i - 1]; + + // Check if previous hash matches + if (currentBlock.previousHash !== previousBlock.hash) { + invalidBlocks.push(currentBlock.index); + } + } + + return { + isValid: invalidBlocks.length === 0, + invalidBlocks, + totalBlocks: blocks.length, + }; +} + +// Get blockchain statistics +export async function getBlockchainStats() { + const [ + totalBlocks, + blocksByType, + latestBlock, + oldestBlock, + ] = await Promise.all([ + prisma.blockchainBlock.count(), + prisma.blockchainBlock.groupBy({ + by: ['blockType'], + _count: { blockType: true }, + }), + prisma.blockchainBlock.findFirst({ orderBy: { index: 'desc' } }), + prisma.blockchainBlock.findFirst({ orderBy: { index: 'asc' } }), + ]); + + return { + totalBlocks, + blocksByType: Object.fromEntries( + blocksByType.map(b => [b.blockType, b._count.blockType]) + ), + latestBlockIndex: latestBlock?.index ?? -1, + latestBlockHash: latestBlock?.hash, + oldestBlockTimestamp: oldestBlock?.timestamp, + latestBlockTimestamp: latestBlock?.timestamp, + }; +} + +// Helper to log entity changes +export async function logEntityChange>( + userId: string | undefined, + action: 'CREATE' | 'UPDATE' | 'DELETE', + entityType: string, + entityId: string, + previousValue: T | null, + newValue: T | null, + metadata?: Record +): Promise { + return createAuditLog({ + userId, + action, + entityType, + entityId, + previousValue: previousValue || undefined, + newValue: newValue || undefined, + metadata, + }); +} diff --git a/lib/db/demand.ts b/lib/db/demand.ts new file mode 100644 index 0000000..9d33a2a --- /dev/null +++ b/lib/db/demand.ts @@ -0,0 +1,597 @@ +/** + * Demand & Market Matching Database Service + * CRUD operations for consumer preferences, demand signals, and market matching + */ + +import prisma from './prisma'; +import type { + ConsumerPreference, + DemandSignal, + SupplyCommitment, + MarketMatch, + SeasonalPlan, + DemandForecast, + PlantingRecommendation, + SupplyStatus, + CommitmentStatus, + MatchStatus, + PlanStatus, + Prisma, +} from '@prisma/client'; +import type { PaginationOptions, PaginatedResult, LocationFilter, DateRangeFilter } from './types'; +import { createPaginatedResult, calculateDistanceKm } from './types'; + +// ============================================ +// CONSUMER PREFERENCE OPERATIONS +// ============================================ + +// Create or update consumer preference +export async function upsertConsumerPreference(data: { + consumerId: string; + latitude: number; + longitude: number; + maxDeliveryRadiusKm?: number; + city?: string; + region?: string; + dietaryType?: string[]; + allergies?: string[]; + dislikes?: string[]; + preferredCategories?: string[]; + preferredItems?: Record[]; + certificationPreferences?: string[]; + freshnessImportance?: number; + priceImportance?: number; + sustainabilityImportance?: number; + deliveryPreferences?: Record; + householdSize?: number; + weeklyBudget?: number; + currency?: string; +}): Promise { + return prisma.consumerPreference.upsert({ + where: { consumerId: data.consumerId }, + update: { + ...data, + updatedAt: new Date(), + }, + create: { + ...data, + maxDeliveryRadiusKm: data.maxDeliveryRadiusKm || 50, + dietaryType: data.dietaryType || [], + allergies: data.allergies || [], + dislikes: data.dislikes || [], + preferredCategories: data.preferredCategories || [], + certificationPreferences: data.certificationPreferences || [], + freshnessImportance: data.freshnessImportance || 3, + priceImportance: data.priceImportance || 3, + sustainabilityImportance: data.sustainabilityImportance || 3, + householdSize: data.householdSize || 1, + currency: data.currency || 'USD', + }, + }); +} + +// Get consumer preference by consumer ID +export async function getConsumerPreference(consumerId: string): Promise { + return prisma.consumerPreference.findUnique({ + where: { consumerId }, + }); +} + +// Get consumer preferences near location +export async function getNearbyConsumerPreferences( + location: LocationFilter +): Promise { + const preferences = await prisma.consumerPreference.findMany(); + + return preferences.filter(pref => { + const distance = calculateDistanceKm( + location.latitude, + location.longitude, + pref.latitude, + pref.longitude + ); + return distance <= location.radiusKm; + }); +} + +// ============================================ +// DEMAND SIGNAL OPERATIONS +// ============================================ + +// Create a demand signal +export async function createDemandSignal(data: { + centerLat: number; + centerLon: number; + radiusKm: number; + regionName: string; + periodStart: Date; + periodEnd: Date; + seasonalPeriod: string; + demandItems: Record[]; + totalConsumers: number; + totalWeeklyDemandKg: number; + confidenceLevel: number; + currentSupplyKg?: number; + supplyGapKg?: number; + supplyStatus?: SupplyStatus; +}): Promise { + return prisma.demandSignal.create({ + data: { + ...data, + supplyStatus: data.supplyStatus || 'BALANCED', + }, + }); +} + +// Get demand signal by ID +export async function getDemandSignalById(id: string): Promise { + return prisma.demandSignal.findUnique({ + where: { id }, + }); +} + +// Get demand signals with pagination +export async function getDemandSignals( + options: PaginationOptions = {}, + filters?: { + regionName?: string; + supplyStatus?: SupplyStatus; + dateRange?: DateRangeFilter; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.DemandSignalWhereInput = {}; + if (filters?.regionName) where.regionName = { contains: filters.regionName, mode: 'insensitive' }; + if (filters?.supplyStatus) where.supplyStatus = filters.supplyStatus; + if (filters?.dateRange) { + where.periodStart = { gte: filters.dateRange.start }; + where.periodEnd = { lte: filters.dateRange.end }; + } + + const [signals, total] = await Promise.all([ + prisma.demandSignal.findMany({ + where, + skip, + take: limit, + orderBy: { timestamp: 'desc' }, + }), + prisma.demandSignal.count({ where }), + ]); + + return createPaginatedResult(signals, total, page, limit); +} + +// Get active demand signals for region +export async function getActiveDemandSignals( + location: LocationFilter +): Promise { + const now = new Date(); + const signals = await prisma.demandSignal.findMany({ + where: { + periodEnd: { gte: now }, + }, + orderBy: { timestamp: 'desc' }, + }); + + return signals.filter(signal => { + const distance = calculateDistanceKm( + location.latitude, + location.longitude, + signal.centerLat, + signal.centerLon + ); + return distance <= signal.radiusKm; + }); +} + +// ============================================ +// SUPPLY COMMITMENT OPERATIONS +// ============================================ + +// Create a supply commitment +export async function createSupplyCommitment(data: { + growerId: string; + produceType: string; + variety?: string; + committedQuantityKg: number; + availableFrom: Date; + availableUntil: Date; + pricePerKg: number; + currency?: string; + minimumOrderKg?: number; + bulkDiscountThreshold?: number; + bulkDiscountPercent?: number; + certifications?: string[]; + freshnessGuaranteeHours?: number; + deliveryRadiusKm: number; + deliveryMethods: string[]; +}): Promise { + return prisma.supplyCommitment.create({ + data: { + ...data, + currency: data.currency || 'USD', + minimumOrderKg: data.minimumOrderKg || 0, + certifications: data.certifications || [], + status: 'AVAILABLE', + remainingKg: data.committedQuantityKg, + }, + }); +} + +// Get supply commitment by ID +export async function getSupplyCommitmentById(id: string): Promise { + return prisma.supplyCommitment.findUnique({ + where: { id }, + }); +} + +// Update supply commitment +export async function updateSupplyCommitment( + id: string, + data: Prisma.SupplyCommitmentUpdateInput +): Promise { + return prisma.supplyCommitment.update({ + where: { id }, + data, + }); +} + +// Reduce commitment quantity +export async function reduceCommitmentQuantity( + id: string, + quantityKg: number +): Promise { + const commitment = await prisma.supplyCommitment.findUnique({ + where: { id }, + }); + + if (!commitment) throw new Error('Commitment not found'); + + const newRemainingKg = commitment.remainingKg - quantityKg; + let newStatus: CommitmentStatus = commitment.status; + + if (newRemainingKg <= 0) { + newStatus = 'FULLY_COMMITTED'; + } else if (newRemainingKg < commitment.committedQuantityKg) { + newStatus = 'PARTIALLY_COMMITTED'; + } + + return prisma.supplyCommitment.update({ + where: { id }, + data: { + remainingKg: Math.max(0, newRemainingKg), + status: newStatus, + }, + }); +} + +// Get supply commitments with pagination +export async function getSupplyCommitments( + options: PaginationOptions = {}, + filters?: { + growerId?: string; + produceType?: string; + status?: CommitmentStatus; + dateRange?: DateRangeFilter; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.SupplyCommitmentWhereInput = {}; + if (filters?.growerId) where.growerId = filters.growerId; + if (filters?.produceType) where.produceType = { contains: filters.produceType, mode: 'insensitive' }; + if (filters?.status) where.status = filters.status; + if (filters?.dateRange) { + where.availableFrom = { lte: filters.dateRange.end }; + where.availableUntil = { gte: filters.dateRange.start }; + } + + const [commitments, total] = await Promise.all([ + prisma.supplyCommitment.findMany({ + where, + skip, + take: limit, + orderBy: { timestamp: 'desc' }, + include: { grower: true }, + }), + prisma.supplyCommitment.count({ where }), + ]); + + return createPaginatedResult(commitments, total, page, limit); +} + +// Find matching supply for demand +export async function findMatchingSupply( + produceType: string, + location: LocationFilter, + requiredDate: Date +): Promise { + const commitments = await prisma.supplyCommitment.findMany({ + where: { + produceType: { contains: produceType, mode: 'insensitive' }, + status: { in: ['AVAILABLE', 'PARTIALLY_COMMITTED'] }, + availableFrom: { lte: requiredDate }, + availableUntil: { gte: requiredDate }, + remainingKg: { gt: 0 }, + }, + include: { grower: true }, + }); + + // Filter by delivery radius + return commitments.filter(c => { + if (!c.grower.latitude || !c.grower.longitude) return false; + const distance = calculateDistanceKm( + location.latitude, + location.longitude, + c.grower.latitude, + c.grower.longitude + ); + return distance <= c.deliveryRadiusKm; + }); +} + +// ============================================ +// MARKET MATCH OPERATIONS +// ============================================ + +// Create a market match +export async function createMarketMatch(data: { + consumerId: string; + growerId: string; + demandSignalId: string; + supplyCommitmentId: string; + produceType: string; + matchedQuantityKg: number; + agreedPricePerKg: number; + totalPrice: number; + currency?: string; + deliveryDate: Date; + deliveryMethod: string; + deliveryLatitude?: number; + deliveryLongitude?: number; + deliveryAddress?: string; +}): Promise { + // Reduce the supply commitment + await reduceCommitmentQuantity(data.supplyCommitmentId, data.matchedQuantityKg); + + return prisma.marketMatch.create({ + data: { + ...data, + currency: data.currency || 'USD', + status: 'PENDING', + }, + }); +} + +// Get market match by ID +export async function getMarketMatchById(id: string): Promise { + return prisma.marketMatch.findUnique({ + where: { id }, + }); +} + +// Get market match with details +export async function getMarketMatchWithDetails(id: string) { + return prisma.marketMatch.findUnique({ + where: { id }, + include: { + demandSignal: true, + supplyCommitment: { + include: { grower: true }, + }, + }, + }); +} + +// Update market match status +export async function updateMarketMatchStatus( + id: string, + status: MatchStatus, + extras?: { + consumerRating?: number; + growerRating?: number; + feedback?: string; + } +): Promise { + return prisma.marketMatch.update({ + where: { id }, + data: { status, ...extras }, + }); +} + +// Get market matches with pagination +export async function getMarketMatches( + options: PaginationOptions = {}, + filters?: { + consumerId?: string; + growerId?: string; + status?: MatchStatus; + dateRange?: DateRangeFilter; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.MarketMatchWhereInput = {}; + if (filters?.consumerId) where.consumerId = filters.consumerId; + if (filters?.growerId) where.growerId = filters.growerId; + if (filters?.status) where.status = filters.status; + if (filters?.dateRange) { + where.deliveryDate = { + gte: filters.dateRange.start, + lte: filters.dateRange.end, + }; + } + + const [matches, total] = await Promise.all([ + prisma.marketMatch.findMany({ + where, + skip, + take: limit, + orderBy: { deliveryDate: 'desc' }, + include: { + demandSignal: true, + supplyCommitment: true, + }, + }), + prisma.marketMatch.count({ where }), + ]); + + return createPaginatedResult(matches, total, page, limit); +} + +// ============================================ +// SEASONAL PLAN OPERATIONS +// ============================================ + +// Create a seasonal plan +export async function createSeasonalPlan(data: { + growerId: string; + year: number; + season: string; + location: Record; + growingCapacity: Record; + plannedCrops: Record[]; + expectedTotalYieldKg?: number; + expectedRevenue?: number; + expectedCarbonFootprintKg?: number; +}): Promise { + return prisma.seasonalPlan.create({ + data: { + ...data, + status: 'DRAFT', + }, + }); +} + +// Get seasonal plan by ID +export async function getSeasonalPlanById(id: string): Promise { + return prisma.seasonalPlan.findUnique({ + where: { id }, + }); +} + +// Update seasonal plan +export async function updateSeasonalPlan( + id: string, + data: Prisma.SeasonalPlanUpdateInput +): Promise { + return prisma.seasonalPlan.update({ + where: { id }, + data, + }); +} + +// Update seasonal plan status +export async function updateSeasonalPlanStatus( + id: string, + status: PlanStatus, + completionPercentage?: number +): Promise { + return prisma.seasonalPlan.update({ + where: { id }, + data: { status, completionPercentage }, + }); +} + +// Get seasonal plans by grower +export async function getSeasonalPlansByGrower( + growerId: string, + year?: number +): Promise { + return prisma.seasonalPlan.findMany({ + where: { + growerId, + ...(year && { year }), + }, + orderBy: [{ year: 'desc' }, { season: 'asc' }], + }); +} + +// ============================================ +// DEMAND FORECAST OPERATIONS +// ============================================ + +// Create a demand forecast +export async function createDemandForecast(data: { + region: string; + forecastPeriodStart: Date; + forecastPeriodEnd: Date; + forecasts: Record[]; + modelVersion: string; + dataPointsUsed: number; + lastTrainingDate?: Date; +}): Promise { + return prisma.demandForecast.create({ data }); +} + +// Get latest demand forecast for region +export async function getLatestDemandForecast(region: string): Promise { + return prisma.demandForecast.findFirst({ + where: { region }, + orderBy: { generatedAt: 'desc' }, + }); +} + +// ============================================ +// PLANTING RECOMMENDATION OPERATIONS +// ============================================ + +// Create a planting recommendation +export async function createPlantingRecommendation(data: { + growerId: string; + produceType: string; + variety?: string; + category: string; + recommendedQuantity: number; + quantityUnit: string; + expectedYieldKg: number; + yieldConfidence: number; + plantByDate: Date; + expectedHarvestStart: Date; + expectedHarvestEnd: Date; + growingDays: number; + projectedDemandKg?: number; + projectedPricePerKg?: number; + projectedRevenue?: number; + marketConfidence?: number; + riskFactors?: Record[]; + overallRisk?: string; + demandSignalIds: string[]; + explanation?: string; +}): Promise { + return prisma.plantingRecommendation.create({ + data: { + ...data, + overallRisk: data.overallRisk || 'medium', + }, + }); +} + +// Get planting recommendations for grower +export async function getPlantingRecommendations( + growerId: string, + options: PaginationOptions = {} +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const [recommendations, total] = await Promise.all([ + prisma.plantingRecommendation.findMany({ + where: { growerId }, + skip, + take: limit, + orderBy: { plantByDate: 'asc' }, + }), + prisma.plantingRecommendation.count({ where: { growerId } }), + ]); + + return createPaginatedResult(recommendations, total, page, limit); +} diff --git a/lib/db/farms.ts b/lib/db/farms.ts new file mode 100644 index 0000000..b981d70 --- /dev/null +++ b/lib/db/farms.ts @@ -0,0 +1,568 @@ +/** + * Vertical Farm Database Service + * CRUD operations for vertical farms, zones, and crop batches + */ + +import prisma from './prisma'; +import type { + VerticalFarm, + GrowingZone, + CropBatch, + GrowingRecipe, + FarmStatus, + ZoneStatus, + CropBatchStatus, + Prisma, +} from '@prisma/client'; +import type { PaginationOptions, PaginatedResult } from './types'; +import { createPaginatedResult } from './types'; + +// ============================================ +// VERTICAL FARM OPERATIONS +// ============================================ + +// Create a vertical farm +export async function createVerticalFarm(data: { + name: string; + ownerId: string; + latitude: number; + longitude: number; + address: string; + city: string; + country: string; + timezone?: string; + specs: Record; + environmentalControl?: Record; + irrigationSystem?: Record; + lightingSystem?: Record; + nutrientSystem?: Record; + automationLevel?: 'MANUAL' | 'SEMI_AUTOMATED' | 'FULLY_AUTOMATED'; + automationSystems?: Record; +}): Promise { + return prisma.verticalFarm.create({ + data: { + ...data, + timezone: data.timezone || 'UTC', + automationLevel: data.automationLevel || 'MANUAL', + status: 'OFFLINE', + }, + }); +} + +// Get vertical farm by ID +export async function getVerticalFarmById(id: string): Promise { + return prisma.verticalFarm.findUnique({ + where: { id }, + }); +} + +// Get vertical farm with zones +export async function getVerticalFarmWithZones(id: string) { + return prisma.verticalFarm.findUnique({ + where: { id }, + include: { + owner: true, + zones: true, + cropBatches: { + where: { status: { not: 'COMPLETED' } }, + }, + }, + }); +} + +// Update vertical farm +export async function updateVerticalFarm( + id: string, + data: Prisma.VerticalFarmUpdateInput +): Promise { + return prisma.verticalFarm.update({ + where: { id }, + data, + }); +} + +// Update farm status +export async function updateFarmStatus( + id: string, + status: FarmStatus +): Promise { + return prisma.verticalFarm.update({ + where: { id }, + data: { status }, + }); +} + +// Delete vertical farm +export async function deleteVerticalFarm(id: string): Promise { + return prisma.verticalFarm.delete({ + where: { id }, + }); +} + +// Get farms with pagination +export async function getVerticalFarms( + options: PaginationOptions = {}, + filters?: { + ownerId?: string; + status?: FarmStatus; + city?: string; + country?: string; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.VerticalFarmWhereInput = {}; + if (filters?.ownerId) where.ownerId = filters.ownerId; + if (filters?.status) where.status = filters.status; + if (filters?.city) where.city = { contains: filters.city, mode: 'insensitive' }; + if (filters?.country) where.country = { contains: filters.country, mode: 'insensitive' }; + + const [farms, total] = await Promise.all([ + prisma.verticalFarm.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + owner: true, + zones: true, + }, + }), + prisma.verticalFarm.count({ where }), + ]); + + return createPaginatedResult(farms, total, page, limit); +} + +// Get farms by owner +export async function getVerticalFarmsByOwner(ownerId: string): Promise { + return prisma.verticalFarm.findMany({ + where: { ownerId }, + include: { zones: true }, + orderBy: { name: 'asc' }, + }); +} + +// ============================================ +// GROWING ZONE OPERATIONS +// ============================================ + +// Create a growing zone +export async function createGrowingZone(data: { + name: string; + farmId: string; + level: number; + areaSqm: number; + lengthM?: number; + widthM?: number; + growingMethod: 'NFT' | 'DWC' | 'EBB_FLOW' | 'AEROPONICS' | 'VERTICAL_TOWERS' | 'RACK_SYSTEM'; + plantPositions: number; + environmentTargets?: Record; +}): Promise { + return prisma.growingZone.create({ + data: { + ...data, + status: 'EMPTY', + }, + }); +} + +// Get growing zone by ID +export async function getGrowingZoneById(id: string): Promise { + return prisma.growingZone.findUnique({ + where: { id }, + }); +} + +// Get growing zone with current batch +export async function getGrowingZoneWithBatch(id: string) { + return prisma.growingZone.findUnique({ + where: { id }, + include: { + farm: true, + cropBatches: { + where: { status: { not: 'COMPLETED' } }, + take: 1, + }, + }, + }); +} + +// Update growing zone +export async function updateGrowingZone( + id: string, + data: Prisma.GrowingZoneUpdateInput +): Promise { + return prisma.growingZone.update({ + where: { id }, + data, + }); +} + +// Update zone status +export async function updateZoneStatus( + id: string, + status: ZoneStatus +): Promise { + return prisma.growingZone.update({ + where: { id }, + data: { status }, + }); +} + +// Delete growing zone +export async function deleteGrowingZone(id: string): Promise { + return prisma.growingZone.delete({ + where: { id }, + }); +} + +// Get zones by farm +export async function getGrowingZonesByFarm(farmId: string): Promise { + return prisma.growingZone.findMany({ + where: { farmId }, + orderBy: [{ level: 'asc' }, { name: 'asc' }], + }); +} + +// Update zone environment readings +export async function updateZoneEnvironment( + id: string, + readings: Record +): Promise { + return prisma.growingZone.update({ + where: { id }, + data: { currentEnvironment: readings }, + }); +} + +// ============================================ +// CROP BATCH OPERATIONS +// ============================================ + +// Create a crop batch +export async function createCropBatch(data: { + farmId: string; + zoneId: string; + cropType: string; + variety?: string; + recipeId?: string; + seedBatchId?: string; + plantCount: number; + plantingDate: Date; + expectedHarvestDate: Date; + expectedYieldKg: number; +}): Promise { + // Update zone status + await prisma.growingZone.update({ + where: { id: data.zoneId }, + data: { + status: 'PLANTED', + currentCrop: data.cropType, + plantingDate: data.plantingDate, + expectedHarvestDate: data.expectedHarvestDate, + }, + }); + + return prisma.cropBatch.create({ + data: { + ...data, + currentStage: 'germinating', + currentDay: 0, + status: 'GERMINATING', + }, + }); +} + +// Get crop batch by ID +export async function getCropBatchById(id: string): Promise { + return prisma.cropBatch.findUnique({ + where: { id }, + }); +} + +// Get crop batch with details +export async function getCropBatchWithDetails(id: string) { + return prisma.cropBatch.findUnique({ + where: { id }, + include: { + farm: true, + zone: true, + recipe: true, + seedBatch: true, + plants: true, + harvestBatches: true, + }, + }); +} + +// Update crop batch +export async function updateCropBatch( + id: string, + data: Prisma.CropBatchUpdateInput +): Promise { + return prisma.cropBatch.update({ + where: { id }, + data, + }); +} + +// Update crop batch status +export async function updateCropBatchStatus( + id: string, + status: CropBatchStatus, + extras?: { + currentStage?: string; + currentDay?: number; + healthScore?: number; + actualHarvestDate?: Date; + actualYieldKg?: number; + qualityGrade?: string; + } +): Promise { + const batch = await prisma.cropBatch.update({ + where: { id }, + data: { status, ...extras }, + }); + + // Update zone status based on batch status + if (status === 'COMPLETED' || status === 'FAILED') { + await prisma.growingZone.update({ + where: { id: batch.zoneId }, + data: { + status: 'CLEANING', + currentCrop: null, + plantingDate: null, + expectedHarvestDate: null, + }, + }); + } else if (status === 'HARVESTING') { + await prisma.growingZone.update({ + where: { id: batch.zoneId }, + data: { status: 'HARVESTING' }, + }); + } else if (status === 'GROWING') { + await prisma.growingZone.update({ + where: { id: batch.zoneId }, + data: { status: 'GROWING' }, + }); + } + + return batch; +} + +// Delete crop batch +export async function deleteCropBatch(id: string): Promise { + return prisma.cropBatch.delete({ + where: { id }, + }); +} + +// Get crop batches with pagination +export async function getCropBatches( + options: PaginationOptions = {}, + filters?: { + farmId?: string; + zoneId?: string; + status?: CropBatchStatus; + cropType?: string; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.CropBatchWhereInput = {}; + if (filters?.farmId) where.farmId = filters.farmId; + if (filters?.zoneId) where.zoneId = filters.zoneId; + if (filters?.status) where.status = filters.status; + if (filters?.cropType) where.cropType = { contains: filters.cropType, mode: 'insensitive' }; + + const [batches, total] = await Promise.all([ + prisma.cropBatch.findMany({ + where, + skip, + take: limit, + orderBy: { plantingDate: 'desc' }, + include: { + zone: true, + recipe: true, + }, + }), + prisma.cropBatch.count({ where }), + ]); + + return createPaginatedResult(batches, total, page, limit); +} + +// Get active batches by farm +export async function getActiveCropBatchesByFarm(farmId: string): Promise { + return prisma.cropBatch.findMany({ + where: { + farmId, + status: { notIn: ['COMPLETED', 'FAILED'] }, + }, + include: { + zone: true, + recipe: true, + }, + orderBy: { expectedHarvestDate: 'asc' }, + }); +} + +// Add issue to crop batch +export async function addCropBatchIssue( + id: string, + issue: { + type: string; + severity: string; + description: string; + affectedPlants: number; + } +) { + const batch = await prisma.cropBatch.findUnique({ + where: { id }, + select: { issues: true }, + }); + + const issues = (batch?.issues as unknown[] || []) as Record[]; + issues.push({ + id: `issue_${Date.now()}`, + timestamp: new Date().toISOString(), + ...issue, + }); + + return prisma.cropBatch.update({ + where: { id }, + data: { issues }, + }); +} + +// ============================================ +// GROWING RECIPE OPERATIONS +// ============================================ + +// Create a growing recipe +export async function createGrowingRecipe(data: { + name: string; + cropType: string; + variety?: string; + version?: string; + stages: Record[]; + expectedDays: number; + expectedYieldGrams: number; + expectedYieldPerSqm?: number; + requirements?: Record; + source?: 'INTERNAL' | 'COMMUNITY' | 'COMMERCIAL'; + author?: string; +}): Promise { + return prisma.growingRecipe.create({ + data: { + ...data, + version: data.version || '1.0', + source: data.source || 'INTERNAL', + timesUsed: 0, + }, + }); +} + +// Get growing recipe by ID +export async function getGrowingRecipeById(id: string): Promise { + return prisma.growingRecipe.findUnique({ + where: { id }, + }); +} + +// Get recipes by crop type +export async function getGrowingRecipesByCrop(cropType: string): Promise { + return prisma.growingRecipe.findMany({ + where: { cropType: { contains: cropType, mode: 'insensitive' } }, + orderBy: { rating: 'desc' }, + }); +} + +// Increment recipe usage +export async function incrementRecipeUsage(id: string): Promise { + return prisma.growingRecipe.update({ + where: { id }, + data: { timesUsed: { increment: 1 } }, + }); +} + +// ============================================ +// RESOURCE & ANALYTICS OPERATIONS +// ============================================ + +// Record resource usage +export async function recordResourceUsage(data: { + farmId: string; + periodStart: Date; + periodEnd: Date; + electricityKwh: number; + electricityCostUsd?: number; + renewablePercent?: number; + peakDemandKw?: number; + waterUsageL: number; + waterCostUsd?: number; + waterRecycledPercent?: number; + co2UsedKg?: number; + co2CostUsd?: number; + nutrientsUsedL?: number; + nutrientCostUsd?: number; +}) { + return prisma.resourceUsage.create({ data }); +} + +// Get resource usage for farm +export async function getResourceUsage(farmId: string, periodStart: Date, periodEnd: Date) { + return prisma.resourceUsage.findMany({ + where: { + farmId, + periodStart: { gte: periodStart }, + periodEnd: { lte: periodEnd }, + }, + orderBy: { periodStart: 'asc' }, + }); +} + +// Record farm analytics +export async function recordFarmAnalytics(data: { + farmId: string; + period: string; + totalYieldKg: number; + yieldPerSqmPerYear?: number; + cropCyclesCompleted: number; + averageCyclesDays?: number; + averageQualityScore?: number; + gradeAPercent?: number; + wastagePercent?: number; + cropSuccessRate?: number; + spaceUtilization?: number; + laborHoursPerKg?: number; + revenueUsd?: number; + costUsd?: number; + profitMarginPercent?: number; + revenuePerSqm?: number; + carbonFootprintKgPerKg?: number; + waterUseLPerKg?: number; + energyUseKwhPerKg?: number; + topCropsByYield?: Record[]; + topCropsByRevenue?: Record[]; + topCropsByEfficiency?: Record[]; +}) { + return prisma.farmAnalytics.create({ data }); +} + +// Get farm analytics +export async function getFarmAnalytics(farmId: string, period?: string) { + const where: Prisma.FarmAnalyticsWhereInput = { farmId }; + if (period) where.period = period; + + return prisma.farmAnalytics.findMany({ + where, + orderBy: { generatedAt: 'desc' }, + take: period ? 1 : 12, + }); +} diff --git a/lib/db/index.ts b/lib/db/index.ts new file mode 100644 index 0000000..d12aea0 --- /dev/null +++ b/lib/db/index.ts @@ -0,0 +1,156 @@ +/** + * LocalGreenChain Database Service Layer + * Central export for all database operations + */ + +// Prisma client singleton +export { default as prisma, prisma as db } from './prisma'; + +// Types and utilities +export * from './types'; + +// User operations +export * as users from './users'; +export { + createUser, + getUserById, + getUserByEmail, + getUserByWalletAddress, + updateUser, + deleteUser, + getUsers, + getUsersByType, + updateLastLogin, + searchUsers, + getUserWithPlants, + getUserWithFarms, + getUserStats, +} from './users'; + +// Plant operations +export * as plants from './plants'; +export { + createPlant, + getPlantById, + getPlantWithOwner, + getPlantWithLineage, + updatePlant, + updatePlantStatus, + deletePlant, + getPlants, + getPlantsByOwner, + getNearbyPlants, + searchPlants, + getPlantLineage, + clonePlant, + getPlantNetworkStats, + updatePlantBlockchain, +} from './plants'; + +// Transport operations +export * as transport from './transport'; +export { + createTransportEvent, + getTransportEventById, + getTransportEventWithDetails, + updateTransportEvent, + updateTransportEventStatus, + deleteTransportEvent, + getTransportEvents, + getTransportEventsByPlant, + getPlantJourney, + getEnvironmentalImpact, + getUserCarbonFootprint, + createSeedBatch, + getSeedBatchById, + createHarvestBatch, + getHarvestBatchById, +} from './transport'; + +// Vertical farm operations +export * as farms from './farms'; +export { + createVerticalFarm, + getVerticalFarmById, + getVerticalFarmWithZones, + updateVerticalFarm, + updateFarmStatus, + deleteVerticalFarm, + getVerticalFarms, + getVerticalFarmsByOwner, + createGrowingZone, + getGrowingZoneById, + getGrowingZoneWithBatch, + updateGrowingZone, + updateZoneStatus, + deleteGrowingZone, + getGrowingZonesByFarm, + updateZoneEnvironment, + createCropBatch, + getCropBatchById, + getCropBatchWithDetails, + updateCropBatch, + updateCropBatchStatus, + deleteCropBatch, + getCropBatches, + getActiveCropBatchesByFarm, + addCropBatchIssue, + createGrowingRecipe, + getGrowingRecipeById, + getGrowingRecipesByCrop, + incrementRecipeUsage, + recordResourceUsage, + getResourceUsage, + recordFarmAnalytics, + getFarmAnalytics, +} from './farms'; + +// Demand and market operations +export * as demand from './demand'; +export { + upsertConsumerPreference, + getConsumerPreference, + getNearbyConsumerPreferences, + createDemandSignal, + getDemandSignalById, + getDemandSignals, + getActiveDemandSignals, + createSupplyCommitment, + getSupplyCommitmentById, + updateSupplyCommitment, + reduceCommitmentQuantity, + getSupplyCommitments, + findMatchingSupply, + createMarketMatch, + getMarketMatchById, + getMarketMatchWithDetails, + updateMarketMatchStatus, + getMarketMatches, + createSeasonalPlan, + getSeasonalPlanById, + updateSeasonalPlan, + updateSeasonalPlanStatus, + getSeasonalPlansByGrower, + createDemandForecast, + getLatestDemandForecast, + createPlantingRecommendation, + getPlantingRecommendations, +} from './demand'; + +// Audit and blockchain operations +export * as audit from './audit'; +export { + createAuditLog, + getAuditLogs, + getEntityAuditLogs, + getUserAuditLogs, + getAuditLogsSummary, + createBlockchainBlock, + getBlockchainBlockByIndex, + getBlockchainBlockByHash, + getLatestBlockchainBlock, + getBlockchainBlocks, + verifyBlockchainIntegrity, + getBlockchainStats, + logEntityChange, +} from './audit'; diff --git a/lib/db/plants.ts b/lib/db/plants.ts new file mode 100644 index 0000000..823ade0 --- /dev/null +++ b/lib/db/plants.ts @@ -0,0 +1,378 @@ +/** + * Plant Database Service + * CRUD operations for plants and lineage tracking + */ + +import prisma from './prisma'; +import type { Plant, PlantStatus, PropagationType, Prisma } from '@prisma/client'; +import type { PaginationOptions, PaginatedResult, LocationFilter, PlantWithLineage } from './types'; +import { createPaginatedResult, calculateDistanceKm } from './types'; + +// Create a new plant +export async function createPlant(data: { + commonName: string; + scientificName?: string; + species?: string; + genus?: string; + family?: string; + parentPlantId?: string; + propagationType?: PropagationType; + generation?: number; + plantedDate: Date; + status?: PlantStatus; + latitude: number; + longitude: number; + address?: string; + city?: string; + country?: string; + ownerId: string; + environment?: Record; + growthMetrics?: Record; + notes?: string; + images?: string[]; + plantsNetId?: string; +}): Promise { + // Determine generation based on parent + let generation = data.generation || 0; + if (data.parentPlantId && generation === 0) { + const parent = await prisma.plant.findUnique({ + where: { id: data.parentPlantId }, + select: { generation: true }, + }); + if (parent) { + generation = parent.generation + 1; + } + } + + return prisma.plant.create({ + data: { + ...data, + generation, + propagationType: data.propagationType || 'ORIGINAL', + status: data.status || 'SPROUTED', + }, + }); +} + +// Get plant by ID +export async function getPlantById(id: string): Promise { + return prisma.plant.findUnique({ + where: { id }, + }); +} + +// Get plant with owner +export async function getPlantWithOwner(id: string) { + return prisma.plant.findUnique({ + where: { id }, + include: { owner: true }, + }); +} + +// Get plant with full lineage +export async function getPlantWithLineage(id: string): Promise { + return prisma.plant.findUnique({ + where: { id }, + include: { + owner: true, + parentPlant: true, + childPlants: true, + }, + }); +} + +// Update plant +export async function updatePlant( + id: string, + data: Prisma.PlantUpdateInput +): Promise { + return prisma.plant.update({ + where: { id }, + data, + }); +} + +// Update plant status +export async function updatePlantStatus( + id: string, + status: PlantStatus, + harvestedDate?: Date +): Promise { + return prisma.plant.update({ + where: { id }, + data: { + status, + ...(harvestedDate && { harvestedDate }), + }, + }); +} + +// Delete plant +export async function deletePlant(id: string): Promise { + return prisma.plant.delete({ + where: { id }, + }); +} + +// Get plants with pagination +export async function getPlants( + options: PaginationOptions = {}, + filters?: { + ownerId?: string; + status?: PlantStatus; + commonName?: string; + species?: string; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.PlantWhereInput = {}; + if (filters?.ownerId) where.ownerId = filters.ownerId; + if (filters?.status) where.status = filters.status; + if (filters?.commonName) where.commonName = { contains: filters.commonName, mode: 'insensitive' }; + if (filters?.species) where.species = { contains: filters.species, mode: 'insensitive' }; + + const [plants, total] = await Promise.all([ + prisma.plant.findMany({ + where, + skip, + take: limit, + orderBy: { registeredAt: 'desc' }, + include: { owner: true }, + }), + prisma.plant.count({ where }), + ]); + + return createPaginatedResult(plants, total, page, limit); +} + +// Get plants by owner +export async function getPlantsByOwner(ownerId: string): Promise { + return prisma.plant.findMany({ + where: { ownerId }, + orderBy: { registeredAt: 'desc' }, + }); +} + +// Get plants near location +export async function getNearbyPlants( + location: LocationFilter, + excludeOwnerId?: string +): Promise> { + // Get all plants and filter by distance + // Note: For production, consider using PostGIS for efficient geo queries + const plants = await prisma.plant.findMany({ + where: excludeOwnerId ? { ownerId: { not: excludeOwnerId } } : undefined, + include: { owner: true }, + }); + + const plantsWithDistance = plants + .map(plant => ({ + ...plant, + distance: calculateDistanceKm( + location.latitude, + location.longitude, + plant.latitude, + plant.longitude + ), + })) + .filter(plant => plant.distance <= location.radiusKm) + .sort((a, b) => a.distance - b.distance); + + return plantsWithDistance; +} + +// Search plants +export async function searchPlants( + query: string, + options: PaginationOptions = {} +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.PlantWhereInput = { + OR: [ + { commonName: { contains: query, mode: 'insensitive' } }, + { scientificName: { contains: query, mode: 'insensitive' } }, + { species: { contains: query, mode: 'insensitive' } }, + { genus: { contains: query, mode: 'insensitive' } }, + { family: { contains: query, mode: 'insensitive' } }, + ], + }; + + const [plants, total] = await Promise.all([ + prisma.plant.findMany({ + where, + skip, + take: limit, + orderBy: { registeredAt: 'desc' }, + include: { owner: true }, + }), + prisma.plant.count({ where }), + ]); + + return createPaginatedResult(plants, total, page, limit); +} + +// Get full lineage (ancestors and descendants) +export async function getPlantLineage(id: string): Promise<{ + plant: Plant | null; + ancestors: Plant[]; + descendants: Plant[]; + siblings: Plant[]; +}> { + const plant = await prisma.plant.findUnique({ + where: { id }, + include: { + parentPlant: true, + childPlants: true, + }, + }); + + if (!plant) { + return { plant: null, ancestors: [], descendants: [], siblings: [] }; + } + + // Get ancestors recursively + const ancestors: Plant[] = []; + let currentParentId = plant.parentPlantId; + while (currentParentId) { + const parent = await prisma.plant.findUnique({ + where: { id: currentParentId }, + }); + if (parent) { + ancestors.push(parent); + currentParentId = parent.parentPlantId; + } else { + break; + } + } + + // Get all descendants recursively + const descendants = await getDescendants(id); + + // Get siblings (other plants from the same parent) + let siblings: Plant[] = []; + if (plant.parentPlantId) { + siblings = await prisma.plant.findMany({ + where: { + parentPlantId: plant.parentPlantId, + id: { not: id }, + }, + }); + } + + return { + plant, + ancestors, + descendants, + siblings, + }; +} + +// Helper to get all descendants +async function getDescendants(plantId: string): Promise { + const children = await prisma.plant.findMany({ + where: { parentPlantId: plantId }, + }); + + const allDescendants: Plant[] = [...children]; + for (const child of children) { + const grandchildren = await getDescendants(child.id); + allDescendants.push(...grandchildren); + } + + return allDescendants; +} + +// Clone a plant +export async function clonePlant( + parentId: string, + ownerId: string, + propagationType: PropagationType = 'CLONE', + overrides?: Partial<{ + latitude: number; + longitude: number; + address: string; + city: string; + country: string; + notes: string; + }> +): Promise { + const parent = await prisma.plant.findUnique({ + where: { id: parentId }, + }); + + if (!parent) { + throw new Error('Parent plant not found'); + } + + return prisma.plant.create({ + data: { + commonName: parent.commonName, + scientificName: parent.scientificName, + species: parent.species, + genus: parent.genus, + family: parent.family, + parentPlantId: parentId, + propagationType, + generation: parent.generation + 1, + plantedDate: new Date(), + status: 'SPROUTED', + latitude: overrides?.latitude || parent.latitude, + longitude: overrides?.longitude || parent.longitude, + address: overrides?.address, + city: overrides?.city || parent.city, + country: overrides?.country || parent.country, + ownerId, + notes: overrides?.notes, + plantsNetId: parent.plantsNetId, + }, + }); +} + +// Get plant network statistics +export async function getPlantNetworkStats() { + const [totalPlants, speciesDistribution, countryDistribution, ownerCount] = await Promise.all([ + prisma.plant.count(), + prisma.plant.groupBy({ + by: ['species'], + _count: { species: true }, + where: { species: { not: null } }, + }), + prisma.plant.groupBy({ + by: ['country'], + _count: { country: true }, + where: { country: { not: null } }, + }), + prisma.user.count({ + where: { ownedPlants: { some: {} } }, + }), + ]); + + return { + totalPlants, + totalOwners: ownerCount, + species: Object.fromEntries( + speciesDistribution.map(s => [s.species || 'unknown', s._count.species]) + ), + globalDistribution: Object.fromEntries( + countryDistribution.map(c => [c.country || 'unknown', c._count.country]) + ), + }; +} + +// Store blockchain data for a plant +export async function updatePlantBlockchain( + id: string, + blockIndex: number, + blockHash: string +): Promise { + return prisma.plant.update({ + where: { id }, + data: { blockIndex, blockHash }, + }); +} diff --git a/lib/db/prisma.ts b/lib/db/prisma.ts new file mode 100644 index 0000000..2caad6c --- /dev/null +++ b/lib/db/prisma.ts @@ -0,0 +1,27 @@ +/** + * Prisma Client Singleton + * Prevents multiple instances during development hot-reloading + */ + +import { PrismaClient } from '@prisma/client'; + +declare global { + // eslint-disable-next-line no-var + var prisma: PrismaClient | undefined; +} + +const prismaClientSingleton = () => { + return new PrismaClient({ + log: process.env.NODE_ENV === 'development' + ? ['query', 'error', 'warn'] + : ['error'], + }); +}; + +export const prisma = globalThis.prisma ?? prismaClientSingleton(); + +if (process.env.NODE_ENV !== 'production') { + globalThis.prisma = prisma; +} + +export default prisma; diff --git a/lib/db/transport.ts b/lib/db/transport.ts new file mode 100644 index 0000000..2901fd4 --- /dev/null +++ b/lib/db/transport.ts @@ -0,0 +1,410 @@ +/** + * Transport Event Database Service + * CRUD operations for transport events and supply chain tracking + */ + +import prisma from './prisma'; +import type { + TransportEvent, + TransportEventType, + TransportMethod, + TransportStatus, + Prisma, +} from '@prisma/client'; +import type { PaginationOptions, PaginatedResult, DateRangeFilter } from './types'; +import { createPaginatedResult, calculateDistanceKm } from './types'; + +// Carbon emission factors (kg CO2 per km per kg of cargo) +const CARBON_FACTORS: Record = { + WALKING: 0, + BICYCLE: 0, + ELECTRIC_VEHICLE: 0.02, + HYBRID_VEHICLE: 0.08, + GASOLINE_VEHICLE: 0.12, + DIESEL_TRUCK: 0.15, + ELECTRIC_TRUCK: 0.03, + REFRIGERATED_TRUCK: 0.25, + RAIL: 0.01, + SHIP: 0.008, + AIR: 0.5, + DRONE: 0.01, + LOCAL_DELIVERY: 0.05, + CUSTOMER_PICKUP: 0.1, +}; + +// Create a transport event +export async function createTransportEvent(data: { + eventType: TransportEventType; + fromLatitude: number; + fromLongitude: number; + fromAddress?: string; + fromCity?: string; + fromCountry?: string; + fromLocationType: 'FARM' | 'GREENHOUSE' | 'VERTICAL_FARM' | 'WAREHOUSE' | 'HUB' | 'MARKET' | 'CONSUMER' | 'SEED_BANK' | 'OTHER'; + fromFacilityId?: string; + fromFacilityName?: string; + toLatitude: number; + toLongitude: number; + toAddress?: string; + toCity?: string; + toCountry?: string; + toLocationType: 'FARM' | 'GREENHOUSE' | 'VERTICAL_FARM' | 'WAREHOUSE' | 'HUB' | 'MARKET' | 'CONSUMER' | 'SEED_BANK' | 'OTHER'; + toFacilityId?: string; + toFacilityName?: string; + durationMinutes: number; + transportMethod: TransportMethod; + senderId: string; + receiverId: string; + notes?: string; + photos?: string[]; + documents?: string[]; + eventData?: Record; + plantIds?: string[]; + seedBatchId?: string; + harvestBatchId?: string; + cargoWeightKg?: number; +}): Promise { + // Calculate distance + const distanceKm = calculateDistanceKm( + data.fromLatitude, + data.fromLongitude, + data.toLatitude, + data.toLongitude + ); + + // Calculate carbon footprint + const cargoWeight = data.cargoWeightKg || 1; + const carbonFootprintKg = distanceKm * CARBON_FACTORS[data.transportMethod] * cargoWeight; + + return prisma.transportEvent.create({ + data: { + eventType: data.eventType, + fromLatitude: data.fromLatitude, + fromLongitude: data.fromLongitude, + fromAddress: data.fromAddress, + fromCity: data.fromCity, + fromCountry: data.fromCountry, + fromLocationType: data.fromLocationType, + fromFacilityId: data.fromFacilityId, + fromFacilityName: data.fromFacilityName, + toLatitude: data.toLatitude, + toLongitude: data.toLongitude, + toAddress: data.toAddress, + toCity: data.toCity, + toCountry: data.toCountry, + toLocationType: data.toLocationType, + toFacilityId: data.toFacilityId, + toFacilityName: data.toFacilityName, + distanceKm, + durationMinutes: data.durationMinutes, + transportMethod: data.transportMethod, + carbonFootprintKg, + senderId: data.senderId, + receiverId: data.receiverId, + status: 'PENDING', + notes: data.notes, + photos: data.photos || [], + documents: data.documents || [], + eventData: data.eventData, + seedBatchId: data.seedBatchId, + harvestBatchId: data.harvestBatchId, + plants: data.plantIds ? { connect: data.plantIds.map(id => ({ id })) } : undefined, + }, + }); +} + +// Get transport event by ID +export async function getTransportEventById(id: string): Promise { + return prisma.transportEvent.findUnique({ + where: { id }, + }); +} + +// Get transport event with related data +export async function getTransportEventWithDetails(id: string) { + return prisma.transportEvent.findUnique({ + where: { id }, + include: { + sender: true, + receiver: true, + plants: true, + seedBatch: true, + harvestBatch: true, + }, + }); +} + +// Update transport event +export async function updateTransportEvent( + id: string, + data: Prisma.TransportEventUpdateInput +): Promise { + return prisma.transportEvent.update({ + where: { id }, + data, + }); +} + +// Update transport event status +export async function updateTransportEventStatus( + id: string, + status: TransportStatus, + signature?: { type: 'sender' | 'receiver' | 'verifier'; signature: string } +): Promise { + const updateData: Prisma.TransportEventUpdateInput = { status }; + + if (signature) { + if (signature.type === 'sender') updateData.senderSignature = signature.signature; + if (signature.type === 'receiver') updateData.receiverSignature = signature.signature; + if (signature.type === 'verifier') updateData.verifierSignature = signature.signature; + } + + return prisma.transportEvent.update({ + where: { id }, + data: updateData, + }); +} + +// Delete transport event +export async function deleteTransportEvent(id: string): Promise { + return prisma.transportEvent.delete({ + where: { id }, + }); +} + +// Get transport events with pagination +export async function getTransportEvents( + options: PaginationOptions = {}, + filters?: { + eventType?: TransportEventType; + senderId?: string; + receiverId?: string; + status?: TransportStatus; + dateRange?: DateRangeFilter; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.TransportEventWhereInput = {}; + if (filters?.eventType) where.eventType = filters.eventType; + if (filters?.senderId) where.senderId = filters.senderId; + if (filters?.receiverId) where.receiverId = filters.receiverId; + if (filters?.status) where.status = filters.status; + if (filters?.dateRange) { + where.timestamp = { + gte: filters.dateRange.start, + lte: filters.dateRange.end, + }; + } + + const [events, total] = await Promise.all([ + prisma.transportEvent.findMany({ + where, + skip, + take: limit, + orderBy: { timestamp: 'desc' }, + include: { + sender: true, + receiver: true, + }, + }), + prisma.transportEvent.count({ where }), + ]); + + return createPaginatedResult(events, total, page, limit); +} + +// Get transport events by plant +export async function getTransportEventsByPlant(plantId: string): Promise { + return prisma.transportEvent.findMany({ + where: { + plants: { + some: { id: plantId }, + }, + }, + orderBy: { timestamp: 'asc' }, + include: { + sender: true, + receiver: true, + }, + }); +} + +// Get plant journey (complete transport history) +export async function getPlantJourney(plantId: string) { + const plant = await prisma.plant.findUnique({ + where: { id: plantId }, + include: { owner: true }, + }); + + if (!plant) return null; + + const events = await prisma.transportEvent.findMany({ + where: { + plants: { + some: { id: plantId }, + }, + }, + orderBy: { timestamp: 'asc' }, + include: { + sender: true, + receiver: true, + }, + }); + + // Calculate totals + const totalFoodMiles = events.reduce((sum, e) => sum + e.distanceKm, 0); + const totalCarbonKg = events.reduce((sum, e) => sum + e.carbonFootprintKg, 0); + const daysInTransit = events.reduce((sum, e) => sum + e.durationMinutes / 1440, 0); + + return { + plantId, + plant, + events, + totalFoodMiles, + totalCarbonKg, + daysInTransit, + generation: plant.generation, + }; +} + +// Get environmental impact summary +export async function getEnvironmentalImpact(filters?: { + userId?: string; + dateRange?: DateRangeFilter; +}) { + const where: Prisma.TransportEventWhereInput = {}; + if (filters?.userId) { + where.OR = [ + { senderId: filters.userId }, + { receiverId: filters.userId }, + ]; + } + if (filters?.dateRange) { + where.timestamp = { + gte: filters.dateRange.start, + lte: filters.dateRange.end, + }; + } + + const events = await prisma.transportEvent.findMany({ where }); + + const totalCarbonKg = events.reduce((sum, e) => sum + e.carbonFootprintKg, 0); + const totalFoodMiles = events.reduce((sum, e) => sum + e.distanceKm, 0); + + // Breakdown by method + const breakdownByMethod: Record = {}; + events.forEach(e => { + if (!breakdownByMethod[e.transportMethod]) { + breakdownByMethod[e.transportMethod] = { distance: 0, carbon: 0 }; + } + breakdownByMethod[e.transportMethod].distance += e.distanceKm; + breakdownByMethod[e.transportMethod].carbon += e.carbonFootprintKg; + }); + + // Breakdown by event type + const breakdownByEventType: Record = {}; + events.forEach(e => { + if (!breakdownByEventType[e.eventType]) { + breakdownByEventType[e.eventType] = { count: 0, carbon: 0 }; + } + breakdownByEventType[e.eventType].count += 1; + breakdownByEventType[e.eventType].carbon += e.carbonFootprintKg; + }); + + return { + totalCarbonKg, + totalFoodMiles, + eventCount: events.length, + breakdownByMethod, + breakdownByEventType, + }; +} + +// Get user's carbon footprint +export async function getUserCarbonFootprint(userId: string) { + const events = await prisma.transportEvent.findMany({ + where: { + OR: [ + { senderId: userId }, + { receiverId: userId }, + ], + }, + }); + + const sent = events.filter(e => e.senderId === userId); + const received = events.filter(e => e.receiverId === userId); + + return { + totalCarbonKg: events.reduce((sum, e) => sum + e.carbonFootprintKg, 0), + totalDistanceKm: events.reduce((sum, e) => sum + e.distanceKm, 0), + sentEvents: sent.length, + receivedEvents: received.length, + sentCarbonKg: sent.reduce((sum, e) => sum + e.carbonFootprintKg, 0), + receivedCarbonKg: received.reduce((sum, e) => sum + e.carbonFootprintKg, 0), + }; +} + +// Seed Batch operations +export async function createSeedBatch(data: { + species: string; + variety?: string; + quantity: number; + quantityUnit?: string; + generation?: number; + germinationRate?: number; + purityPercentage?: number; + harvestDate?: Date; + expirationDate?: Date; + certifications?: string[]; +}) { + return prisma.seedBatch.create({ + data: { + ...data, + quantityUnit: data.quantityUnit || 'seeds', + generation: data.generation || 0, + status: 'AVAILABLE', + }, + }); +} + +export async function getSeedBatchById(id: string) { + return prisma.seedBatch.findUnique({ + where: { id }, + }); +} + +// Harvest Batch operations +export async function createHarvestBatch(data: { + produceType: string; + harvestType?: string; + grossWeight: number; + netWeight: number; + weightUnit?: string; + itemCount?: number; + qualityGrade?: string; + qualityNotes?: string; + packagingType?: string; + shelfLifeHours?: number; + cropBatchId?: string; +}) { + return prisma.harvestBatch.create({ + data: { + ...data, + harvestType: data.harvestType || 'full', + weightUnit: data.weightUnit || 'kg', + }, + }); +} + +export async function getHarvestBatchById(id: string) { + return prisma.harvestBatch.findUnique({ + where: { id }, + include: { + transportEvents: true, + cropBatch: true, + }, + }); +} diff --git a/lib/db/types.ts b/lib/db/types.ts new file mode 100644 index 0000000..379540e --- /dev/null +++ b/lib/db/types.ts @@ -0,0 +1,150 @@ +/** + * Database Types and Utilities + * Type helpers for working with Prisma and the database + */ + +import type { Prisma } from '@prisma/client'; + +// Re-export Prisma types for convenience +export type { + User, + Plant, + TransportEvent, + VerticalFarm, + GrowingZone, + CropBatch, + GrowingRecipe, + SeedBatch, + HarvestBatch, + ConsumerPreference, + DemandSignal, + SupplyCommitment, + MarketMatch, + SeasonalPlan, + DemandForecast, + PlantingRecommendation, + ResourceUsage, + FarmAnalytics, + AuditLog, + BlockchainBlock, +} from '@prisma/client'; + +// Common query options +export interface PaginationOptions { + page?: number; + limit?: number; + cursor?: string; +} + +export interface SortOptions { + field: string; + direction: 'asc' | 'desc'; +} + +// Location-based query options +export interface LocationFilter { + latitude: number; + longitude: number; + radiusKm: number; +} + +// Date range filter +export interface DateRangeFilter { + start: Date; + end: Date; +} + +// Create input types for common operations +export type CreateUserInput = Prisma.UserCreateInput; +export type UpdateUserInput = Prisma.UserUpdateInput; + +export type CreatePlantInput = Prisma.PlantCreateInput; +export type UpdatePlantInput = Prisma.PlantUpdateInput; + +export type CreateTransportEventInput = Prisma.TransportEventCreateInput; +export type UpdateTransportEventInput = Prisma.TransportEventUpdateInput; + +export type CreateVerticalFarmInput = Prisma.VerticalFarmCreateInput; +export type UpdateVerticalFarmInput = Prisma.VerticalFarmUpdateInput; + +export type CreateCropBatchInput = Prisma.CropBatchCreateInput; +export type UpdateCropBatchInput = Prisma.CropBatchUpdateInput; + +// Result types with includes +export type PlantWithOwner = Prisma.PlantGetPayload<{ + include: { owner: true }; +}>; + +export type PlantWithLineage = Prisma.PlantGetPayload<{ + include: { + owner: true; + parentPlant: true; + childPlants: true; + }; +}>; + +export type TransportEventWithParties = Prisma.TransportEventGetPayload<{ + include: { + sender: true; + receiver: true; + plants: true; + }; +}>; + +export type VerticalFarmWithZones = Prisma.VerticalFarmGetPayload<{ + include: { + owner: true; + zones: true; + cropBatches: true; + }; +}>; + +// Utility type for pagination results +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + limit: number; + totalPages: number; + hasMore: boolean; +} + +// Helper function to calculate distance between two points (Haversine formula) +export function calculateDistanceKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number +): number { + const R = 6371; // Earth's radius in km + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + +function toRad(deg: number): number { + return deg * (Math.PI / 180); +} + +// Helper to create pagination result +export function createPaginatedResult( + items: T[], + total: number, + page: number, + limit: number +): PaginatedResult { + const totalPages = Math.ceil(total / limit); + return { + items, + total, + page, + limit, + totalPages, + hasMore: page < totalPages, + }; +} diff --git a/lib/db/users.ts b/lib/db/users.ts new file mode 100644 index 0000000..afaea83 --- /dev/null +++ b/lib/db/users.ts @@ -0,0 +1,199 @@ +/** + * User Database Service + * CRUD operations for users + */ + +import prisma from './prisma'; +import type { User, UserType, Prisma } from '@prisma/client'; +import type { PaginationOptions, PaginatedResult } from './types'; +import { createPaginatedResult } from './types'; + +// Create a new user +export async function createUser(data: { + email: string; + name: string; + walletAddress?: string; + passwordHash?: string; + avatarUrl?: string; + bio?: string; + latitude?: number; + longitude?: number; + address?: string; + city?: string; + country?: string; + userType?: UserType; +}): Promise { + return prisma.user.create({ + data: { + ...data, + userType: data.userType || 'CONSUMER', + }, + }); +} + +// Get user by ID +export async function getUserById(id: string): Promise { + return prisma.user.findUnique({ + where: { id }, + }); +} + +// Get user by email +export async function getUserByEmail(email: string): Promise { + return prisma.user.findUnique({ + where: { email }, + }); +} + +// Get user by wallet address +export async function getUserByWalletAddress(walletAddress: string): Promise { + return prisma.user.findUnique({ + where: { walletAddress }, + }); +} + +// Update user +export async function updateUser( + id: string, + data: Prisma.UserUpdateInput +): Promise { + return prisma.user.update({ + where: { id }, + data, + }); +} + +// Delete user +export async function deleteUser(id: string): Promise { + return prisma.user.delete({ + where: { id }, + }); +} + +// Get all users with pagination +export async function getUsers( + options: PaginationOptions = {}, + filters?: { + userType?: UserType; + city?: string; + country?: string; + } +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.UserWhereInput = {}; + if (filters?.userType) where.userType = filters.userType; + if (filters?.city) where.city = { contains: filters.city, mode: 'insensitive' }; + if (filters?.country) where.country = { contains: filters.country, mode: 'insensitive' }; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + }), + prisma.user.count({ where }), + ]); + + return createPaginatedResult(users, total, page, limit); +} + +// Get users by type +export async function getUsersByType(userType: UserType): Promise { + return prisma.user.findMany({ + where: { userType }, + orderBy: { name: 'asc' }, + }); +} + +// Update last login +export async function updateLastLogin(id: string): Promise { + return prisma.user.update({ + where: { id }, + data: { lastLoginAt: new Date() }, + }); +} + +// Search users +export async function searchUsers( + query: string, + options: PaginationOptions = {} +): Promise> { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + const where: Prisma.UserWhereInput = { + OR: [ + { name: { contains: query, mode: 'insensitive' } }, + { email: { contains: query, mode: 'insensitive' } }, + { city: { contains: query, mode: 'insensitive' } }, + ], + }; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + skip, + take: limit, + orderBy: { name: 'asc' }, + }), + prisma.user.count({ where }), + ]); + + return createPaginatedResult(users, total, page, limit); +} + +// Get user with their plants +export async function getUserWithPlants(id: string) { + return prisma.user.findUnique({ + where: { id }, + include: { + ownedPlants: { + orderBy: { registeredAt: 'desc' }, + }, + }, + }); +} + +// Get user with their farms +export async function getUserWithFarms(id: string) { + return prisma.user.findUnique({ + where: { id }, + include: { + verticalFarms: { + include: { + zones: true, + }, + }, + }, + }); +} + +// Get user statistics +export async function getUserStats(id: string) { + const [ + plantCount, + farmCount, + transportEventsSent, + transportEventsReceived, + supplyCommitments, + ] = await Promise.all([ + prisma.plant.count({ where: { ownerId: id } }), + prisma.verticalFarm.count({ where: { ownerId: id } }), + prisma.transportEvent.count({ where: { senderId: id } }), + prisma.transportEvent.count({ where: { receiverId: id } }), + prisma.supplyCommitment.count({ where: { growerId: id } }), + ]); + + return { + plantCount, + farmCount, + transportEventsSent, + transportEventsReceived, + supplyCommitments, + }; +} diff --git a/package.json b/package.json index b1350a8..318e1df 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,16 @@ "cy:open": "cypress open", "cy:run": "cypress run", "test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open", - "test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run" + "test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:migrate:prod": "prisma migrate deploy", + "db:seed": "bun run prisma/seed.ts", + "db:studio": "prisma studio" }, "dependencies": { + "@prisma/client": "^5.7.0", "@tailwindcss/forms": "^0.4.0", "@tailwindcss/typography": "^0.5.1", "@tanstack/react-query": "^4.0.10", @@ -41,6 +48,7 @@ "eslint-config-next": "^12.0.10", "jest": "^29.5.0", "postcss": "^8.4.5", + "prisma": "^5.7.0", "tailwindcss": "^3.0.15", "ts-jest": "^29.1.0", "typescript": "^4.5.5" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..eb44f54 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,1084 @@ +// LocalGreenChain Database Schema +// Prisma ORM configuration for PostgreSQL + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// USER & AUTHENTICATION +// ============================================ + +model User { + id String @id @default(cuid()) + email String @unique + name String + walletAddress String? @unique + passwordHash String? + + // Profile + avatarUrl String? + bio String? + + // Location + latitude Float? + longitude Float? + address String? + city String? + country String? + + // User type + userType UserType @default(CONSUMER) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLoginAt DateTime? + + // Relations + ownedPlants Plant[] + verticalFarms VerticalFarm[] + consumerPreferences ConsumerPreference[] + supplyCommitments SupplyCommitment[] + sentTransportEvents TransportEvent[] @relation("Sender") + receivedTransportEvents TransportEvent[] @relation("Receiver") + seasonalPlans SeasonalPlan[] + auditLogs AuditLog[] + + @@index([email]) + @@index([userType]) +} + +enum UserType { + CONSUMER + GROWER + DISTRIBUTOR + PROCESSOR + ADMIN +} + +// ============================================ +// PLANT & LINEAGE TRACKING +// ============================================ + +model Plant { + id String @id @default(cuid()) + + // Plant identification + commonName String + scientificName String? + species String? + genus String? + family String? + + // Lineage tracking + parentPlantId String? + parentPlant Plant? @relation("PlantLineage", fields: [parentPlantId], references: [id]) + childPlants Plant[] @relation("PlantLineage") + propagationType PropagationType @default(ORIGINAL) + generation Int @default(0) + + // Lifecycle + plantedDate DateTime + harvestedDate DateTime? + status PlantStatus @default(SPROUTED) + + // Location + latitude Float + longitude Float + address String? + city String? + country String? + + // Owner + ownerId String + owner User @relation(fields: [ownerId], references: [id]) + + // Environmental data (JSON for flexibility) + environment Json? + growthMetrics Json? + + // Metadata + notes String? + images String[] + plantsNetId String? + + // Blockchain integration + blockIndex Int? + blockHash String? + + // Timestamps + registeredAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + transportEvents TransportEvent[] + cropBatches CropBatch[] + + @@index([ownerId]) + @@index([commonName]) + @@index([status]) + @@index([parentPlantId]) + @@index([latitude, longitude]) +} + +enum PropagationType { + SEED + CLONE + CUTTING + DIVISION + GRAFTING + ORIGINAL +} + +enum PlantStatus { + SPROUTED + GROWING + MATURE + FLOWERING + FRUITING + DORMANT + DECEASED +} + +// ============================================ +// TRANSPORT & SUPPLY CHAIN +// ============================================ + +model TransportEvent { + id String @id @default(cuid()) + + // Event type + eventType TransportEventType + + // Locations + fromLatitude Float + fromLongitude Float + fromAddress String? + fromCity String? + fromCountry String? + fromLocationType LocationType + fromFacilityId String? + fromFacilityName String? + + toLatitude Float + toLongitude Float + toAddress String? + toCity String? + toCountry String? + toLocationType LocationType + toFacilityId String? + toFacilityName String? + + // Distance and duration + distanceKm Float + durationMinutes Int + + // Environmental impact + transportMethod TransportMethod + carbonFootprintKg Float + + // Parties + senderId String + sender User @relation("Sender", fields: [senderId], references: [id]) + receiverId String + receiver User @relation("Receiver", fields: [receiverId], references: [id]) + + // Verification signatures + senderSignature String? + receiverSignature String? + verifierSignature String? + + // Status + status TransportStatus @default(PENDING) + + // Metadata + notes String? + photos String[] + documents String[] + + // Extended event data (JSON for type-specific fields) + eventData Json? + + // Blockchain integration + blockIndex Int? + blockHash String? + cumulativeCarbonKg Float? + cumulativeFoodMiles Float? + + // Timestamps + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + plants Plant[] + seedBatch SeedBatch? @relation(fields: [seedBatchId], references: [id]) + seedBatchId String? + harvestBatch HarvestBatch? @relation(fields: [harvestBatchId], references: [id]) + harvestBatchId String? + + @@index([eventType]) + @@index([senderId]) + @@index([receiverId]) + @@index([status]) + @@index([timestamp]) +} + +enum TransportEventType { + SEED_ACQUISITION + PLANTING + GROWING_TRANSPORT + HARVEST + PROCESSING + DISTRIBUTION + CONSUMER_DELIVERY + SEED_SAVING + SEED_SHARING +} + +enum LocationType { + FARM + GREENHOUSE + VERTICAL_FARM + WAREHOUSE + HUB + MARKET + CONSUMER + SEED_BANK + OTHER +} + +enum TransportMethod { + WALKING + BICYCLE + ELECTRIC_VEHICLE + HYBRID_VEHICLE + GASOLINE_VEHICLE + DIESEL_TRUCK + ELECTRIC_TRUCK + REFRIGERATED_TRUCK + RAIL + SHIP + AIR + DRONE + LOCAL_DELIVERY + CUSTOMER_PICKUP +} + +enum TransportStatus { + PENDING + IN_TRANSIT + DELIVERED + VERIFIED + DISPUTED +} + +// Seed Batch for tracking seeds through the system +model SeedBatch { + id String @id @default(cuid()) + + // Seed info + species String + variety String? + quantity Float + quantityUnit String @default("seeds") + + // Lineage + geneticLineageId String? + parentPlantIds String[] + generation Int @default(0) + + // Quality + germinationRate Float? + purityPercentage Float? + harvestDate DateTime? + expirationDate DateTime? + + // Certifications + certifications String[] + certificationDocs String[] + + // Storage + storageTemperature Float? + storageHumidity Float? + containerType String? + + // Status + status SeedBatchStatus @default(AVAILABLE) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + transportEvents TransportEvent[] + cropBatches CropBatch[] + + @@index([species]) + @@index([status]) +} + +enum SeedBatchStatus { + AVAILABLE + PARTIALLY_USED + DEPLETED + EXPIRED +} + +// Harvest Batch for tracking harvested produce +model HarvestBatch { + id String @id @default(cuid()) + + // Harvest info + produceType String + harvestType String @default("full") + + // Quantities + grossWeight Float + netWeight Float + weightUnit String @default("kg") + itemCount Int? + + // Quality + qualityGrade String? + qualityNotes String? + + // Storage + packagingType String? + shelfLifeHours Int? + temperatureMin Float? + temperatureMax Float? + + // Timestamps + harvestedAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + transportEvents TransportEvent[] + cropBatch CropBatch? @relation(fields: [cropBatchId], references: [id]) + cropBatchId String? + + @@index([produceType]) + @@index([harvestedAt]) +} + +// ============================================ +// VERTICAL FARMING +// ============================================ + +model VerticalFarm { + id String @id @default(cuid()) + name String + + // Owner + ownerId String + owner User @relation(fields: [ownerId], references: [id]) + + // Location + latitude Float + longitude Float + address String + city String + country String + timezone String @default("UTC") + + // Facility specs (JSON for complex nested structure) + specs Json + + // Systems configuration (JSON for flexibility) + environmentalControl Json? + irrigationSystem Json? + lightingSystem Json? + nutrientSystem Json? + + // Automation + automationLevel AutomationLevel @default(MANUAL) + automationSystems Json? + + // Status + status FarmStatus @default(OFFLINE) + operationalSince DateTime? + lastMaintenanceDate DateTime? + + // Performance metrics + capacityUtilization Float? + yieldEfficiency Float? + energyEfficiencyScore Float? + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + zones GrowingZone[] + cropBatches CropBatch[] + resourceUsage ResourceUsage[] + farmAnalytics FarmAnalytics[] + + @@index([ownerId]) + @@index([status]) + @@index([city, country]) +} + +enum AutomationLevel { + MANUAL + SEMI_AUTOMATED + FULLY_AUTOMATED +} + +enum FarmStatus { + OFFLINE + STARTING + OPERATIONAL + MAINTENANCE + EMERGENCY +} + +model GrowingZone { + id String @id @default(cuid()) + name String + level Int + + // Dimensions + areaSqm Float + lengthM Float? + widthM Float? + + // Growing system + growingMethod GrowingMethod + plantPositions Int + + // Current status + currentCrop String? + plantingDate DateTime? + expectedHarvestDate DateTime? + + // Environment targets (JSON) + environmentTargets Json? + + // Current readings (JSON) + currentEnvironment Json? + + // Status + status ZoneStatus @default(EMPTY) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + farm VerticalFarm @relation(fields: [farmId], references: [id], onDelete: Cascade) + farmId String + cropBatches CropBatch[] + + @@index([farmId]) + @@index([status]) +} + +enum GrowingMethod { + NFT + DWC + EBB_FLOW + AEROPONICS + VERTICAL_TOWERS + RACK_SYSTEM +} + +enum ZoneStatus { + EMPTY + PREPARING + PLANTED + GROWING + HARVESTING + CLEANING +} + +model CropBatch { + id String @id @default(cuid()) + + // Crop info + cropType String + variety String? + + // Growing + plantCount Int + plantingDate DateTime + transplantDate DateTime? + + // Progress + currentStage String @default("germinating") + currentDay Int @default(0) + healthScore Float? + + // Expected + expectedHarvestDate DateTime + expectedYieldKg Float + + // Actual (after harvest) + actualHarvestDate DateTime? + actualYieldKg Float? + qualityGrade String? + + // Status + status CropBatchStatus @default(GERMINATING) + + // Issues (JSON array) + issues Json? + + // Environmental log (JSON array) + environmentLog Json? + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + farm VerticalFarm @relation(fields: [farmId], references: [id]) + farmId String + zone GrowingZone @relation(fields: [zoneId], references: [id]) + zoneId String + recipe GrowingRecipe? @relation(fields: [recipeId], references: [id]) + recipeId String? + seedBatch SeedBatch? @relation(fields: [seedBatchId], references: [id]) + seedBatchId String? + plants Plant[] + harvestBatches HarvestBatch[] + + @@index([farmId]) + @@index([zoneId]) + @@index([status]) + @@index([cropType]) +} + +enum CropBatchStatus { + GERMINATING + GROWING + READY + HARVESTING + COMPLETED + FAILED +} + +model GrowingRecipe { + id String @id @default(cuid()) + name String + cropType String + variety String? + version String @default("1.0") + + // Stages (JSON array of GrowthStage) + stages Json + + // Expected outcomes + expectedDays Int + expectedYieldGrams Float + expectedYieldPerSqm Float? + + // Requirements (JSON) + requirements Json? + + // Source + source RecipeSource @default(INTERNAL) + author String? + rating Float? + timesUsed Int @default(0) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + cropBatches CropBatch[] + + @@index([cropType]) + @@index([source]) +} + +enum RecipeSource { + INTERNAL + COMMUNITY + COMMERCIAL +} + +model ResourceUsage { + id String @id @default(cuid()) + + // Period + periodStart DateTime + periodEnd DateTime + + // Energy + electricityKwh Float + electricityCostUsd Float? + renewablePercent Float? + peakDemandKw Float? + + // Water + waterUsageL Float + waterCostUsd Float? + waterRecycledPercent Float? + + // CO2 + co2UsedKg Float? + co2CostUsd Float? + + // Nutrients + nutrientsUsedL Float? + nutrientCostUsd Float? + + // Efficiency metrics + kwhPerKgProduce Float? + litersPerKgProduce Float? + costPerKgProduce Float? + + // Timestamps + createdAt DateTime @default(now()) + + // Relations + farm VerticalFarm @relation(fields: [farmId], references: [id]) + farmId String + + @@index([farmId]) + @@index([periodStart, periodEnd]) +} + +model FarmAnalytics { + id String @id @default(cuid()) + + // Period + period String + generatedAt DateTime @default(now()) + + // Production + totalYieldKg Float + yieldPerSqmPerYear Float? + cropCyclesCompleted Int + averageCyclesDays Float? + + // Quality + averageQualityScore Float? + gradeAPercent Float? + wastagePercent Float? + + // Efficiency + cropSuccessRate Float? + spaceUtilization Float? + laborHoursPerKg Float? + + // Financial + revenueUsd Float? + costUsd Float? + profitMarginPercent Float? + revenuePerSqm Float? + + // Environmental + carbonFootprintKgPerKg Float? + waterUseLPerKg Float? + energyUseKwhPerKg Float? + + // Top crops (JSON arrays) + topCropsByYield Json? + topCropsByRevenue Json? + topCropsByEfficiency Json? + + // Timestamps + createdAt DateTime @default(now()) + + // Relations + farm VerticalFarm @relation(fields: [farmId], references: [id]) + farmId String + + @@index([farmId]) + @@index([period]) +} + +// ============================================ +// DEMAND & MARKET MATCHING +// ============================================ + +model ConsumerPreference { + id String @id @default(cuid()) + + // Consumer + consumerId String + consumer User @relation(fields: [consumerId], references: [id]) + + // Location + latitude Float + longitude Float + maxDeliveryRadiusKm Float @default(50) + city String? + region String? + + // Dietary preferences + dietaryType String[] + allergies String[] + dislikes String[] + + // Produce preferences (JSON) + preferredCategories String[] + preferredItems Json? + + // Quality preferences + certificationPreferences String[] + freshnessImportance Int @default(3) + priceImportance Int @default(3) + sustainabilityImportance Int @default(3) + + // Delivery preferences (JSON) + deliveryPreferences Json? + + // Household + householdSize Int @default(1) + weeklyBudget Float? + currency String? @default("USD") + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([consumerId]) + @@index([latitude, longitude]) +} + +model DemandSignal { + id String @id @default(cuid()) + + // Region scope + centerLat Float + centerLon Float + radiusKm Float + regionName String + + // Time scope + periodStart DateTime + periodEnd DateTime + seasonalPeriod String // spring, summer, fall, winter + + // Aggregated demand (JSON array of DemandItem) + demandItems Json + + // Statistics + totalConsumers Int + totalWeeklyDemandKg Float + confidenceLevel Float + + // Supply status + currentSupplyKg Float? + supplyGapKg Float? + supplyStatus SupplyStatus @default(BALANCED) + + // Timestamps + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) + + // Relations + marketMatches MarketMatch[] + + @@index([regionName]) + @@index([periodStart, periodEnd]) + @@index([supplyStatus]) +} + +enum SupplyStatus { + SURPLUS + BALANCED + SHORTAGE + CRITICAL +} + +model SupplyCommitment { + id String @id @default(cuid()) + + // Grower + growerId String + grower User @relation(fields: [growerId], references: [id]) + + // Produce + produceType String + variety String? + + // Commitment + committedQuantityKg Float + availableFrom DateTime + availableUntil DateTime + + // Pricing + pricePerKg Float + currency String @default("USD") + minimumOrderKg Float @default(0) + bulkDiscountThreshold Float? + bulkDiscountPercent Float? + + // Quality + certifications String[] + freshnessGuaranteeHours Int? + + // Delivery + deliveryRadiusKm Float + deliveryMethods String[] + + // Status + status CommitmentStatus @default(AVAILABLE) + remainingKg Float + + // Timestamps + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + marketMatches MarketMatch[] + + @@index([growerId]) + @@index([produceType]) + @@index([status]) + @@index([availableFrom, availableUntil]) +} + +enum CommitmentStatus { + AVAILABLE + PARTIALLY_COMMITTED + FULLY_COMMITTED + EXPIRED +} + +model MarketMatch { + id String @id @default(cuid()) + + // Parties + consumerId String + growerId String + + // Links + demandSignalId String + demandSignal DemandSignal @relation(fields: [demandSignalId], references: [id]) + supplyCommitmentId String + supplyCommitment SupplyCommitment @relation(fields: [supplyCommitmentId], references: [id]) + + // Match details + produceType String + matchedQuantityKg Float + + // Transaction + agreedPricePerKg Float + totalPrice Float + currency String @default("USD") + + // Delivery + deliveryDate DateTime + deliveryMethod String + deliveryLatitude Float? + deliveryLongitude Float? + deliveryAddress String? + + // Status + status MatchStatus @default(PENDING) + + // Ratings + consumerRating Float? + growerRating Float? + feedback String? + + // Timestamps + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consumerId]) + @@index([growerId]) + @@index([status]) + @@index([deliveryDate]) +} + +enum MatchStatus { + PENDING + CONFIRMED + IN_TRANSIT + DELIVERED + COMPLETED + CANCELLED +} + +model SeasonalPlan { + id String @id @default(cuid()) + + // Grower + growerId String + grower User @relation(fields: [growerId], references: [id]) + + // Season + year Int + season String // spring, summer, fall, winter + + // Location context (JSON) + location Json + + // Growing capacity (JSON) + growingCapacity Json + + // Planned crops (JSON array) + plannedCrops Json + + // Expected outcomes + expectedTotalYieldKg Float? + expectedRevenue Float? + expectedCarbonFootprintKg Float? + + // Status + status PlanStatus @default(DRAFT) + completionPercentage Float? + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([growerId]) + @@index([year, season]) + @@index([status]) +} + +enum PlanStatus { + DRAFT + CONFIRMED + IN_PROGRESS + COMPLETED +} + +model DemandForecast { + id String @id @default(cuid()) + + // Scope + region String + forecastPeriodStart DateTime + forecastPeriodEnd DateTime + + // Forecasts (JSON array of ProduceForecast) + forecasts Json + + // Model info + modelVersion String + dataPointsUsed Int + lastTrainingDate DateTime? + + // Timestamps + generatedAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([region]) + @@index([forecastPeriodStart, forecastPeriodEnd]) +} + +model PlantingRecommendation { + id String @id @default(cuid()) + + // Grower + growerId String + + // Recommendation + produceType String + variety String? + category String + + // Quantities + recommendedQuantity Float + quantityUnit String + expectedYieldKg Float + yieldConfidence Float + + // Timing + plantByDate DateTime + expectedHarvestStart DateTime + expectedHarvestEnd DateTime + growingDays Int + + // Market opportunity + projectedDemandKg Float? + projectedPricePerKg Float? + projectedRevenue Float? + marketConfidence Float? + + // Risk assessment (JSON) + riskFactors Json? + overallRisk String @default("medium") + + // Reasoning + demandSignalIds String[] + explanation String? + + // Timestamps + timestamp DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([growerId]) + @@index([produceType]) + @@index([plantByDate]) +} + +// ============================================ +// AUDIT & TRANSPARENCY +// ============================================ + +model AuditLog { + id String @id @default(cuid()) + + // Actor + userId String? + user User? @relation(fields: [userId], references: [id]) + + // Action + action String + entityType String + entityId String? + + // Details + previousValue Json? + newValue Json? + metadata Json? + + // Source + ipAddress String? + userAgent String? + + // Timestamps + timestamp DateTime @default(now()) + + @@index([userId]) + @@index([entityType, entityId]) + @@index([action]) + @@index([timestamp]) +} + +model BlockchainBlock { + id String @id @default(cuid()) + + // Block data + index Int @unique + timestamp DateTime + previousHash String + hash String @unique + nonce Int + + // Block content type + blockType String // "plant" or "transport" + + // Content reference + plantId String? + transportEventId String? + + // Content snapshot (JSON) + content Json + + // Timestamps + createdAt DateTime @default(now()) + + @@index([blockType]) + @@index([hash]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..746e0bc --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,835 @@ +/** + * LocalGreenChain Database Seed Script + * Populates the database with development data + * + * Run with: bun run db:seed + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Starting database seed...'); + + // Clean existing data (in reverse dependency order) + console.log('Cleaning existing data...'); + await prisma.auditLog.deleteMany(); + await prisma.blockchainBlock.deleteMany(); + await prisma.marketMatch.deleteMany(); + await prisma.plantingRecommendation.deleteMany(); + await prisma.demandForecast.deleteMany(); + await prisma.seasonalPlan.deleteMany(); + await prisma.supplyCommitment.deleteMany(); + await prisma.demandSignal.deleteMany(); + await prisma.consumerPreference.deleteMany(); + await prisma.farmAnalytics.deleteMany(); + await prisma.resourceUsage.deleteMany(); + await prisma.harvestBatch.deleteMany(); + await prisma.cropBatch.deleteMany(); + await prisma.growingRecipe.deleteMany(); + await prisma.growingZone.deleteMany(); + await prisma.verticalFarm.deleteMany(); + await prisma.transportEvent.deleteMany(); + await prisma.seedBatch.deleteMany(); + await prisma.plant.deleteMany(); + await prisma.user.deleteMany(); + + // Create users + console.log('Creating users...'); + const users = await Promise.all([ + prisma.user.create({ + data: { + email: 'alice@localgreenchain.io', + name: 'Alice Green', + userType: 'GROWER', + latitude: 37.7749, + longitude: -122.4194, + city: 'San Francisco', + country: 'USA', + bio: 'Urban farmer specializing in leafy greens and microgreens', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }, + }), + prisma.user.create({ + data: { + email: 'bob@localgreenchain.io', + name: 'Bob Farmer', + userType: 'GROWER', + latitude: 37.8044, + longitude: -122.2712, + city: 'Oakland', + country: 'USA', + bio: 'Vertical farm operator with 5 years experience', + walletAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + }, + }), + prisma.user.create({ + data: { + email: 'carol@localgreenchain.io', + name: 'Carol Consumer', + userType: 'CONSUMER', + latitude: 37.7849, + longitude: -122.4094, + city: 'San Francisco', + country: 'USA', + bio: 'Health-conscious consumer supporting local farms', + }, + }), + prisma.user.create({ + data: { + email: 'david@localgreenchain.io', + name: 'David Distributor', + userType: 'DISTRIBUTOR', + latitude: 37.7649, + longitude: -122.3994, + city: 'San Francisco', + country: 'USA', + bio: 'Local food distributor serving Bay Area restaurants', + }, + }), + prisma.user.create({ + data: { + email: 'eve@localgreenchain.io', + name: 'Eve Admin', + userType: 'ADMIN', + latitude: 37.7949, + longitude: -122.4294, + city: 'San Francisco', + country: 'USA', + bio: 'LocalGreenChain platform administrator', + }, + }), + ]); + + console.log(`Created ${users.length} users`); + + // Create plants + console.log('Creating plants...'); + const plants = await Promise.all([ + // Original tomato plant + prisma.plant.create({ + data: { + commonName: 'Cherry Tomato', + scientificName: 'Solanum lycopersicum var. cerasiforme', + species: 'lycopersicum', + genus: 'Solanum', + family: 'Solanaceae', + propagationType: 'ORIGINAL', + generation: 0, + plantedDate: new Date('2024-03-15'), + status: 'MATURE', + latitude: 37.7749, + longitude: -122.4194, + city: 'San Francisco', + country: 'USA', + ownerId: users[0].id, + notes: 'Heirloom variety from local seed library', + images: ['tomato-seedling.jpg', 'tomato-mature.jpg'], + }, + }), + // Basil plant + prisma.plant.create({ + data: { + commonName: 'Sweet Basil', + scientificName: 'Ocimum basilicum', + species: 'basilicum', + genus: 'Ocimum', + family: 'Lamiaceae', + propagationType: 'SEED', + generation: 1, + plantedDate: new Date('2024-04-01'), + status: 'GROWING', + latitude: 37.7749, + longitude: -122.4194, + city: 'San Francisco', + country: 'USA', + ownerId: users[0].id, + notes: 'Italian large leaf variety', + }, + }), + // Lettuce + prisma.plant.create({ + data: { + commonName: 'Butterhead Lettuce', + scientificName: 'Lactuca sativa var. capitata', + species: 'sativa', + genus: 'Lactuca', + family: 'Asteraceae', + propagationType: 'SEED', + generation: 0, + plantedDate: new Date('2024-05-01'), + status: 'GROWING', + latitude: 37.8044, + longitude: -122.2712, + city: 'Oakland', + country: 'USA', + ownerId: users[1].id, + notes: 'Vertical farm grown with LED lighting', + }, + }), + // Kale + prisma.plant.create({ + data: { + commonName: 'Curly Kale', + scientificName: 'Brassica oleracea var. sabellica', + species: 'oleracea', + genus: 'Brassica', + family: 'Brassicaceae', + propagationType: 'SEED', + generation: 0, + plantedDate: new Date('2024-04-15'), + status: 'MATURE', + latitude: 37.8044, + longitude: -122.2712, + city: 'Oakland', + country: 'USA', + ownerId: users[1].id, + notes: 'Organic certified', + }, + }), + // Microgreens + prisma.plant.create({ + data: { + commonName: 'Sunflower Microgreens', + scientificName: 'Helianthus annuus', + species: 'annuus', + genus: 'Helianthus', + family: 'Asteraceae', + propagationType: 'SEED', + generation: 0, + plantedDate: new Date('2024-05-20'), + status: 'SPROUTED', + latitude: 37.7749, + longitude: -122.4194, + city: 'San Francisco', + country: 'USA', + ownerId: users[0].id, + notes: 'Fast-growing microgreen variety', + }, + }), + ]); + + console.log(`Created ${plants.length} plants`); + + // Create tomato clone (child plant) + const tomatoClone = await prisma.plant.create({ + data: { + commonName: 'Cherry Tomato Clone', + scientificName: 'Solanum lycopersicum var. cerasiforme', + species: 'lycopersicum', + genus: 'Solanum', + family: 'Solanaceae', + parentPlantId: plants[0].id, + propagationType: 'CLONE', + generation: 1, + plantedDate: new Date('2024-04-20'), + status: 'GROWING', + latitude: 37.8044, + longitude: -122.2712, + city: 'Oakland', + country: 'USA', + ownerId: users[1].id, + notes: 'Cloned from Alice\'s heirloom tomato', + }, + }); + + console.log('Created tomato clone'); + + // Create seed batches + console.log('Creating seed batches...'); + const seedBatches = await Promise.all([ + prisma.seedBatch.create({ + data: { + species: 'Solanum lycopersicum', + variety: 'Cherry Heirloom', + quantity: 100, + quantityUnit: 'seeds', + generation: 0, + germinationRate: 0.92, + purityPercentage: 0.99, + harvestDate: new Date('2023-10-15'), + expirationDate: new Date('2026-10-15'), + certifications: ['organic', 'heirloom'], + status: 'AVAILABLE', + }, + }), + prisma.seedBatch.create({ + data: { + species: 'Lactuca sativa', + variety: 'Butterhead', + quantity: 500, + quantityUnit: 'seeds', + generation: 0, + germinationRate: 0.88, + harvestDate: new Date('2024-01-20'), + expirationDate: new Date('2027-01-20'), + certifications: ['organic', 'non_gmo'], + status: 'AVAILABLE', + }, + }), + ]); + + console.log(`Created ${seedBatches.length} seed batches`); + + // Create vertical farm + console.log('Creating vertical farms...'); + const farm = await prisma.verticalFarm.create({ + data: { + name: 'Oakland Urban Greens', + ownerId: users[1].id, + latitude: 37.8044, + longitude: -122.2712, + address: '123 Industrial Blvd', + city: 'Oakland', + country: 'USA', + timezone: 'America/Los_Angeles', + specs: { + totalAreaSqm: 500, + growingAreaSqm: 400, + numberOfLevels: 5, + ceilingHeightM: 4, + totalGrowingPositions: 2000, + currentActivePlants: 1500, + powerCapacityKw: 100, + waterStorageL: 5000, + backupPowerHours: 8, + certifications: ['gap', 'haccp'], + buildingType: 'warehouse', + insulation: 'high_efficiency', + }, + environmentalControl: { + hvacUnits: [ + { id: 'hvac1', type: 'heat_pump', capacityKw: 20, status: 'running' }, + { id: 'hvac2', type: 'cooling', capacityKw: 15, status: 'running' }, + ], + co2Injection: { type: 'tank', capacityKg: 50, currentLevelKg: 35 }, + }, + lightingSystem: { + type: 'LED', + totalWattage: 50000, + efficacyUmolJ: 2.8, + }, + automationLevel: 'SEMI_AUTOMATED', + status: 'OPERATIONAL', + operationalSince: new Date('2023-06-01'), + capacityUtilization: 0.75, + yieldEfficiency: 0.85, + energyEfficiencyScore: 0.78, + }, + }); + + console.log('Created vertical farm'); + + // Create growing zones + console.log('Creating growing zones...'); + const zones = await Promise.all([ + prisma.growingZone.create({ + data: { + name: 'Zone A - Leafy Greens', + farmId: farm.id, + level: 1, + areaSqm: 80, + lengthM: 10, + widthM: 8, + growingMethod: 'NFT', + plantPositions: 400, + currentCrop: 'Butterhead Lettuce', + plantingDate: new Date('2024-05-01'), + expectedHarvestDate: new Date('2024-05-28'), + environmentTargets: { + temperatureC: { min: 18, max: 24, target: 21 }, + humidityPercent: { min: 60, max: 80, target: 70 }, + co2Ppm: { min: 800, max: 1200, target: 1000 }, + lightPpfd: { min: 200, max: 400, target: 300 }, + lightHours: 16, + }, + currentEnvironment: { + timestamp: new Date().toISOString(), + temperatureC: 21.5, + humidityPercent: 68, + co2Ppm: 950, + ppfd: 310, + waterTempC: 20, + ec: 1.4, + ph: 6.2, + }, + status: 'GROWING', + }, + }), + prisma.growingZone.create({ + data: { + name: 'Zone B - Herbs', + farmId: farm.id, + level: 2, + areaSqm: 60, + lengthM: 10, + widthM: 6, + growingMethod: 'DWC', + plantPositions: 300, + currentCrop: 'Sweet Basil', + plantingDate: new Date('2024-05-10'), + expectedHarvestDate: new Date('2024-06-10'), + status: 'GROWING', + }, + }), + prisma.growingZone.create({ + data: { + name: 'Zone C - Microgreens', + farmId: farm.id, + level: 3, + areaSqm: 40, + growingMethod: 'RACK_SYSTEM', + plantPositions: 500, + status: 'EMPTY', + }, + }), + ]); + + console.log(`Created ${zones.length} growing zones`); + + // Create growing recipe + console.log('Creating growing recipes...'); + const recipe = await prisma.growingRecipe.create({ + data: { + name: 'Butterhead Lettuce - Fast Cycle', + cropType: 'Lettuce', + variety: 'Butterhead', + version: '2.0', + stages: [ + { + name: 'Germination', + daysStart: 0, + daysEnd: 3, + temperature: { day: 20, night: 18 }, + humidity: { day: 80, night: 85 }, + lightHours: 18, + lightPpfd: 150, + }, + { + name: 'Seedling', + daysStart: 4, + daysEnd: 10, + temperature: { day: 21, night: 18 }, + humidity: { day: 70, night: 75 }, + lightHours: 16, + lightPpfd: 250, + }, + { + name: 'Vegetative', + daysStart: 11, + daysEnd: 24, + temperature: { day: 22, night: 18 }, + humidity: { day: 65, night: 70 }, + lightHours: 16, + lightPpfd: 350, + }, + { + name: 'Harvest', + daysStart: 25, + daysEnd: 28, + temperature: { day: 18, night: 16 }, + humidity: { day: 60, night: 65 }, + lightHours: 12, + lightPpfd: 300, + }, + ], + expectedDays: 28, + expectedYieldGrams: 150, + expectedYieldPerSqm: 3.5, + requirements: { + positions: 1, + zoneType: 'NFT', + minimumPpfd: 200, + idealTemperatureC: 20, + }, + source: 'INTERNAL', + author: 'Bob Farmer', + rating: 4.5, + timesUsed: 15, + }, + }); + + console.log('Created growing recipe'); + + // Create crop batch + console.log('Creating crop batches...'); + const cropBatch = await prisma.cropBatch.create({ + data: { + farmId: farm.id, + zoneId: zones[0].id, + cropType: 'Butterhead Lettuce', + variety: 'Bibb', + recipeId: recipe.id, + seedBatchId: seedBatches[1].id, + plantCount: 400, + plantingDate: new Date('2024-05-01'), + expectedHarvestDate: new Date('2024-05-28'), + expectedYieldKg: 60, + currentStage: 'vegetative', + currentDay: 18, + healthScore: 92, + status: 'GROWING', + environmentLog: [ + { + timestamp: new Date('2024-05-15').toISOString(), + readings: { temperatureC: 21, humidityPercent: 68, ppfd: 320 }, + }, + { + timestamp: new Date('2024-05-18').toISOString(), + readings: { temperatureC: 21.5, humidityPercent: 67, ppfd: 325 }, + }, + ], + }, + }); + + console.log('Created crop batch'); + + // Create transport events + console.log('Creating transport events...'); + const transportEvents = await Promise.all([ + prisma.transportEvent.create({ + data: { + eventType: 'SEED_ACQUISITION', + fromLatitude: 37.7849, + fromLongitude: -122.4094, + fromLocationType: 'SEED_BANK', + fromFacilityName: 'Bay Area Seed Library', + toLatitude: 37.7749, + toLongitude: -122.4194, + toLocationType: 'FARM', + toFacilityName: 'Alice\'s Urban Farm', + distanceKm: 1.2, + durationMinutes: 15, + transportMethod: 'BICYCLE', + carbonFootprintKg: 0, + senderId: users[2].id, + receiverId: users[0].id, + status: 'VERIFIED', + notes: 'Picked up heirloom tomato seeds', + eventData: { + seedBatchId: seedBatches[0].id, + quantity: 50, + sourceType: 'seed_library', + }, + }, + }), + prisma.transportEvent.create({ + data: { + eventType: 'DISTRIBUTION', + fromLatitude: 37.8044, + fromLongitude: -122.2712, + fromLocationType: 'VERTICAL_FARM', + fromFacilityName: 'Oakland Urban Greens', + toLatitude: 37.7849, + toLongitude: -122.4094, + toLocationType: 'MARKET', + toFacilityName: 'Ferry Building Farmers Market', + distanceKm: 8.5, + durationMinutes: 25, + transportMethod: 'ELECTRIC_TRUCK', + carbonFootprintKg: 0.51, + senderId: users[1].id, + receiverId: users[3].id, + status: 'DELIVERED', + notes: 'Weekly lettuce delivery', + eventData: { + batchIds: [cropBatch.id], + quantityKg: 45, + destinationType: 'market', + }, + }, + }), + ]); + + console.log(`Created ${transportEvents.length} transport events`); + + // Create consumer preference + console.log('Creating consumer preferences...'); + await prisma.consumerPreference.create({ + data: { + consumerId: users[2].id, + latitude: 37.7849, + longitude: -122.4094, + maxDeliveryRadiusKm: 15, + city: 'San Francisco', + region: 'Bay Area', + dietaryType: ['vegetarian', 'flexitarian'], + allergies: [], + dislikes: ['cilantro'], + preferredCategories: ['leafy_greens', 'herbs', 'microgreens'], + preferredItems: [ + { produceType: 'Lettuce', category: 'leafy_greens', priority: 'must_have' }, + { produceType: 'Basil', category: 'herbs', priority: 'preferred' }, + { produceType: 'Kale', category: 'leafy_greens', priority: 'nice_to_have' }, + ], + certificationPreferences: ['organic', 'local'], + freshnessImportance: 5, + priceImportance: 3, + sustainabilityImportance: 5, + deliveryPreferences: { + method: ['farmers_market', 'home_delivery'], + frequency: 'weekly', + preferredDays: ['saturday', 'sunday'], + }, + householdSize: 2, + weeklyBudget: 75, + currency: 'USD', + }, + }); + + console.log('Created consumer preference'); + + // Create demand signal + console.log('Creating demand signals...'); + const demandSignal = await prisma.demandSignal.create({ + data: { + centerLat: 37.7849, + centerLon: -122.4094, + radiusKm: 20, + regionName: 'San Francisco Bay Area', + periodStart: new Date('2024-05-01'), + periodEnd: new Date('2024-05-31'), + seasonalPeriod: 'spring', + demandItems: [ + { + produceType: 'Butterhead Lettuce', + category: 'leafy_greens', + weeklyDemandKg: 150, + monthlyDemandKg: 600, + consumerCount: 45, + aggregatePriority: 8, + urgency: 'this_week', + inSeason: true, + }, + { + produceType: 'Sweet Basil', + category: 'herbs', + weeklyDemandKg: 30, + monthlyDemandKg: 120, + consumerCount: 32, + aggregatePriority: 7, + urgency: 'this_week', + inSeason: true, + }, + ], + totalConsumers: 78, + totalWeeklyDemandKg: 180, + confidenceLevel: 85, + currentSupplyKg: 120, + supplyGapKg: 60, + supplyStatus: 'SHORTAGE', + }, + }); + + console.log('Created demand signal'); + + // Create supply commitment + console.log('Creating supply commitments...'); + const supplyCommitment = await prisma.supplyCommitment.create({ + data: { + growerId: users[1].id, + produceType: 'Butterhead Lettuce', + variety: 'Bibb', + committedQuantityKg: 60, + availableFrom: new Date('2024-05-28'), + availableUntil: new Date('2024-06-15'), + pricePerKg: 8.5, + currency: 'USD', + minimumOrderKg: 2, + bulkDiscountThreshold: 20, + bulkDiscountPercent: 10, + certifications: ['gap', 'local'], + freshnessGuaranteeHours: 24, + deliveryRadiusKm: 25, + deliveryMethods: ['grower_delivery', 'customer_pickup'], + status: 'AVAILABLE', + remainingKg: 60, + }, + }); + + console.log('Created supply commitment'); + + // Create market match + console.log('Creating market matches...'); + await prisma.marketMatch.create({ + data: { + consumerId: users[2].id, + growerId: users[1].id, + demandSignalId: demandSignal.id, + supplyCommitmentId: supplyCommitment.id, + produceType: 'Butterhead Lettuce', + matchedQuantityKg: 4, + agreedPricePerKg: 8.5, + totalPrice: 34, + currency: 'USD', + deliveryDate: new Date('2024-05-30'), + deliveryMethod: 'farmers_market', + deliveryLatitude: 37.7955, + deliveryLongitude: -122.3937, + deliveryAddress: 'Ferry Building, San Francisco', + status: 'CONFIRMED', + }, + }); + + console.log('Created market match'); + + // Create seasonal plan + console.log('Creating seasonal plans...'); + await prisma.seasonalPlan.create({ + data: { + growerId: users[1].id, + year: 2024, + season: 'summer', + location: { + latitude: 37.8044, + longitude: -122.2712, + hardinessZone: '10a', + }, + growingCapacity: { + verticalFarmSqMeters: 400, + hydroponicUnits: 5, + }, + plannedCrops: [ + { + produceType: 'Butterhead Lettuce', + plantingDate: '2024-06-01', + quantity: 500, + expectedYieldKg: 75, + status: 'planned', + }, + { + produceType: 'Sweet Basil', + plantingDate: '2024-06-15', + quantity: 300, + expectedYieldKg: 15, + status: 'planned', + }, + ], + expectedTotalYieldKg: 90, + expectedRevenue: 765, + status: 'DRAFT', + }, + }); + + console.log('Created seasonal plan'); + + // Create audit logs + console.log('Creating audit logs...'); + await Promise.all([ + prisma.auditLog.create({ + data: { + userId: users[0].id, + action: 'CREATE', + entityType: 'Plant', + entityId: plants[0].id, + newValue: { commonName: 'Cherry Tomato', status: 'SPROUTED' }, + metadata: { source: 'web_app' }, + }, + }), + prisma.auditLog.create({ + data: { + userId: users[1].id, + action: 'CREATE', + entityType: 'VerticalFarm', + entityId: farm.id, + newValue: { name: 'Oakland Urban Greens' }, + metadata: { source: 'web_app' }, + }, + }), + prisma.auditLog.create({ + data: { + userId: users[1].id, + action: 'UPDATE', + entityType: 'CropBatch', + entityId: cropBatch.id, + previousValue: { status: 'GERMINATING' }, + newValue: { status: 'GROWING' }, + metadata: { source: 'automated_agent' }, + }, + }), + ]); + + console.log('Created audit logs'); + + // Create blockchain blocks + console.log('Creating blockchain blocks...'); + await Promise.all([ + prisma.blockchainBlock.create({ + data: { + index: 0, + timestamp: new Date('2024-01-01'), + previousHash: '0', + hash: '0000000000000000000000000000000000000000000000000000000000000000', + nonce: 0, + blockType: 'genesis', + content: { message: 'LocalGreenChain Genesis Block' }, + }, + }), + prisma.blockchainBlock.create({ + data: { + index: 1, + timestamp: new Date('2024-03-15'), + previousHash: '0000000000000000000000000000000000000000000000000000000000000000', + hash: '1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890', + nonce: 42, + blockType: 'plant', + plantId: plants[0].id, + content: { + plantId: plants[0].id, + commonName: 'Cherry Tomato', + action: 'REGISTERED', + }, + }, + }), + ]); + + console.log('Created blockchain blocks'); + + // Create resource usage records + console.log('Creating resource usage records...'); + await prisma.resourceUsage.create({ + data: { + farmId: farm.id, + periodStart: new Date('2024-05-01'), + periodEnd: new Date('2024-05-15'), + electricityKwh: 1500, + electricityCostUsd: 225, + renewablePercent: 60, + peakDemandKw: 45, + waterUsageL: 3000, + waterCostUsd: 15, + waterRecycledPercent: 85, + co2UsedKg: 20, + co2CostUsd: 40, + nutrientsUsedL: 50, + nutrientCostUsd: 75, + kwhPerKgProduce: 25, + litersPerKgProduce: 50, + costPerKgProduce: 5.92, + }, + }); + + console.log('Created resource usage records'); + + // Summary + console.log('\n=== Seed Complete ==='); + console.log(`Users: ${users.length}`); + console.log(`Plants: ${plants.length + 1}`); + console.log(`Seed Batches: ${seedBatches.length}`); + console.log(`Vertical Farms: 1`); + console.log(`Growing Zones: ${zones.length}`); + console.log(`Growing Recipes: 1`); + console.log(`Crop Batches: 1`); + console.log(`Transport Events: ${transportEvents.length}`); + console.log('\nDatabase seeded successfully!'); +} + +main() + .catch((e) => { + console.error('Seed error:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });