feat(db): implement PostgreSQL database integration with Prisma ORM
Agent 2 - Database Integration (P0 Critical): - Add Prisma ORM with PostgreSQL for persistent data storage - Create comprehensive database schema with 20+ models: - User & authentication models - Plant & lineage tracking - Transport events & supply chain - Vertical farming (farms, zones, batches, recipes) - Demand & market matching - Audit logging & blockchain storage - Implement complete database service layer (lib/db/): - users.ts: User CRUD with search and stats - plants.ts: Plant operations with lineage tracking - transport.ts: Transport events and carbon tracking - farms.ts: Vertical farm and crop batch management - demand.ts: Consumer preferences and market matching - audit.ts: Audit logging and blockchain integrity - Add PlantChainDB for database-backed blockchain - Create development seed script with sample data - Add database documentation (docs/DATABASE.md) - Update package.json with Prisma dependencies and scripts This provides the foundation for all other agents to build upon with persistent, scalable data storage.
This commit is contained in:
parent
705105d9b6
commit
3d2ccdc29a
16 changed files with 5529 additions and 1 deletions
12
.env.example
12
.env.example
|
|
@ -1,5 +1,17 @@
|
||||||
# LocalGreenChain Environment Variables
|
# 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 (optional)
|
||||||
PLANTS_NET_API_KEY=your_api_key_here
|
PLANTS_NET_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
|
|
||||||
363
docs/DATABASE.md
Normal file
363
docs/DATABASE.md
Normal file
|
|
@ -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*
|
||||||
419
lib/blockchain/PlantChainDB.ts
Normal file
419
lib/blockchain/PlantChainDB.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
const latestBlock = await db.getLatestBlockchainBlock();
|
||||||
|
if (!latestBlock) {
|
||||||
|
await this.createGenesisBlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the genesis block
|
||||||
|
*/
|
||||||
|
private async createGenesisBlock(): Promise<BlockchainBlock> {
|
||||||
|
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<BlockchainBlock | null> {
|
||||||
|
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<PlantData | null> {
|
||||||
|
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<Plant | null> {
|
||||||
|
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<PlantLineage | null> {
|
||||||
|
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<NearbyPlant[]> {
|
||||||
|
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<PlantNetwork> {
|
||||||
|
return db.getPlantNetworkStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the blockchain integrity
|
||||||
|
*/
|
||||||
|
async isChainValid(): Promise<boolean> {
|
||||||
|
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<PlantChainDB> {
|
||||||
|
if (!plantChainInstance) {
|
||||||
|
plantChainInstance = new PlantChainDB();
|
||||||
|
await plantChainInstance.initialize();
|
||||||
|
}
|
||||||
|
return plantChainInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlantChainDB;
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
/**
|
/**
|
||||||
* Blockchain Manager
|
* Blockchain Manager
|
||||||
* Singleton to manage the global plant blockchain instance
|
* 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 { PlantChain } from './PlantChain';
|
||||||
|
import { PlantChainDB, getPlantChain as getDBPlantChain } from './PlantChainDB';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const BLOCKCHAIN_FILE = path.join(process.cwd(), 'data', 'plantchain.json');
|
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 {
|
class BlockchainManager {
|
||||||
private static instance: BlockchainManager;
|
private static instance: BlockchainManager;
|
||||||
private plantChain: PlantChain;
|
private plantChain: PlantChain;
|
||||||
|
|
@ -104,3 +112,21 @@ export function getBlockchain(): PlantChain {
|
||||||
export function saveBlockchain(): void {
|
export function saveBlockchain(): void {
|
||||||
BlockchainManager.getInstance().saveBlockchain();
|
BlockchainManager.getInstance().saveBlockchain();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the database-backed blockchain instance
|
||||||
|
* Use this for production applications with PostgreSQL
|
||||||
|
*/
|
||||||
|
export async function getBlockchainDB(): Promise<PlantChainDB> {
|
||||||
|
return getDBPlantChain();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if using database storage
|
||||||
|
*/
|
||||||
|
export function isUsingDatabase(): boolean {
|
||||||
|
return USE_DATABASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the DB class for type usage
|
||||||
|
export { PlantChainDB };
|
||||||
|
|
|
||||||
296
lib/db/audit.ts
Normal file
296
lib/db/audit.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
newValue?: Record<string, unknown>;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}): Promise<AuditLog> {
|
||||||
|
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<PaginatedResult<AuditLog>> {
|
||||||
|
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<AuditLog[]> {
|
||||||
|
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<PaginatedResult<AuditLog>> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}): Promise<BlockchainBlock> {
|
||||||
|
return prisma.blockchainBlock.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get blockchain block by index
|
||||||
|
export async function getBlockchainBlockByIndex(index: number): Promise<BlockchainBlock | null> {
|
||||||
|
return prisma.blockchainBlock.findUnique({
|
||||||
|
where: { index },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get blockchain block by hash
|
||||||
|
export async function getBlockchainBlockByHash(hash: string): Promise<BlockchainBlock | null> {
|
||||||
|
return prisma.blockchainBlock.findUnique({
|
||||||
|
where: { hash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest blockchain block
|
||||||
|
export async function getLatestBlockchainBlock(): Promise<BlockchainBlock | null> {
|
||||||
|
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<PaginatedResult<BlockchainBlock>> {
|
||||||
|
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<T extends Record<string, unknown>>(
|
||||||
|
userId: string | undefined,
|
||||||
|
action: 'CREATE' | 'UPDATE' | 'DELETE',
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
previousValue: T | null,
|
||||||
|
newValue: T | null,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
): Promise<AuditLog> {
|
||||||
|
return createAuditLog({
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
previousValue: previousValue || undefined,
|
||||||
|
newValue: newValue || undefined,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
597
lib/db/demand.ts
Normal file
597
lib/db/demand.ts
Normal file
|
|
@ -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<string, unknown>[];
|
||||||
|
certificationPreferences?: string[];
|
||||||
|
freshnessImportance?: number;
|
||||||
|
priceImportance?: number;
|
||||||
|
sustainabilityImportance?: number;
|
||||||
|
deliveryPreferences?: Record<string, unknown>;
|
||||||
|
householdSize?: number;
|
||||||
|
weeklyBudget?: number;
|
||||||
|
currency?: string;
|
||||||
|
}): Promise<ConsumerPreference> {
|
||||||
|
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<ConsumerPreference | null> {
|
||||||
|
return prisma.consumerPreference.findUnique({
|
||||||
|
where: { consumerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get consumer preferences near location
|
||||||
|
export async function getNearbyConsumerPreferences(
|
||||||
|
location: LocationFilter
|
||||||
|
): Promise<ConsumerPreference[]> {
|
||||||
|
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<string, unknown>[];
|
||||||
|
totalConsumers: number;
|
||||||
|
totalWeeklyDemandKg: number;
|
||||||
|
confidenceLevel: number;
|
||||||
|
currentSupplyKg?: number;
|
||||||
|
supplyGapKg?: number;
|
||||||
|
supplyStatus?: SupplyStatus;
|
||||||
|
}): Promise<DemandSignal> {
|
||||||
|
return prisma.demandSignal.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
supplyStatus: data.supplyStatus || 'BALANCED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get demand signal by ID
|
||||||
|
export async function getDemandSignalById(id: string): Promise<DemandSignal | null> {
|
||||||
|
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<PaginatedResult<DemandSignal>> {
|
||||||
|
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<DemandSignal[]> {
|
||||||
|
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<SupplyCommitment> {
|
||||||
|
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<SupplyCommitment | null> {
|
||||||
|
return prisma.supplyCommitment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update supply commitment
|
||||||
|
export async function updateSupplyCommitment(
|
||||||
|
id: string,
|
||||||
|
data: Prisma.SupplyCommitmentUpdateInput
|
||||||
|
): Promise<SupplyCommitment> {
|
||||||
|
return prisma.supplyCommitment.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce commitment quantity
|
||||||
|
export async function reduceCommitmentQuantity(
|
||||||
|
id: string,
|
||||||
|
quantityKg: number
|
||||||
|
): Promise<SupplyCommitment> {
|
||||||
|
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<PaginatedResult<SupplyCommitment>> {
|
||||||
|
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<SupplyCommitment[]> {
|
||||||
|
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<MarketMatch> {
|
||||||
|
// 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<MarketMatch | null> {
|
||||||
|
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<MarketMatch> {
|
||||||
|
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<PaginatedResult<MarketMatch>> {
|
||||||
|
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<string, unknown>;
|
||||||
|
growingCapacity: Record<string, unknown>;
|
||||||
|
plannedCrops: Record<string, unknown>[];
|
||||||
|
expectedTotalYieldKg?: number;
|
||||||
|
expectedRevenue?: number;
|
||||||
|
expectedCarbonFootprintKg?: number;
|
||||||
|
}): Promise<SeasonalPlan> {
|
||||||
|
return prisma.seasonalPlan.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
status: 'DRAFT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get seasonal plan by ID
|
||||||
|
export async function getSeasonalPlanById(id: string): Promise<SeasonalPlan | null> {
|
||||||
|
return prisma.seasonalPlan.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update seasonal plan
|
||||||
|
export async function updateSeasonalPlan(
|
||||||
|
id: string,
|
||||||
|
data: Prisma.SeasonalPlanUpdateInput
|
||||||
|
): Promise<SeasonalPlan> {
|
||||||
|
return prisma.seasonalPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update seasonal plan status
|
||||||
|
export async function updateSeasonalPlanStatus(
|
||||||
|
id: string,
|
||||||
|
status: PlanStatus,
|
||||||
|
completionPercentage?: number
|
||||||
|
): Promise<SeasonalPlan> {
|
||||||
|
return prisma.seasonalPlan.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status, completionPercentage },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get seasonal plans by grower
|
||||||
|
export async function getSeasonalPlansByGrower(
|
||||||
|
growerId: string,
|
||||||
|
year?: number
|
||||||
|
): Promise<SeasonalPlan[]> {
|
||||||
|
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<string, unknown>[];
|
||||||
|
modelVersion: string;
|
||||||
|
dataPointsUsed: number;
|
||||||
|
lastTrainingDate?: Date;
|
||||||
|
}): Promise<DemandForecast> {
|
||||||
|
return prisma.demandForecast.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest demand forecast for region
|
||||||
|
export async function getLatestDemandForecast(region: string): Promise<DemandForecast | null> {
|
||||||
|
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<string, unknown>[];
|
||||||
|
overallRisk?: string;
|
||||||
|
demandSignalIds: string[];
|
||||||
|
explanation?: string;
|
||||||
|
}): Promise<PlantingRecommendation> {
|
||||||
|
return prisma.plantingRecommendation.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
overallRisk: data.overallRisk || 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get planting recommendations for grower
|
||||||
|
export async function getPlantingRecommendations(
|
||||||
|
growerId: string,
|
||||||
|
options: PaginationOptions = {}
|
||||||
|
): Promise<PaginatedResult<PlantingRecommendation>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
568
lib/db/farms.ts
Normal file
568
lib/db/farms.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
environmentalControl?: Record<string, unknown>;
|
||||||
|
irrigationSystem?: Record<string, unknown>;
|
||||||
|
lightingSystem?: Record<string, unknown>;
|
||||||
|
nutrientSystem?: Record<string, unknown>;
|
||||||
|
automationLevel?: 'MANUAL' | 'SEMI_AUTOMATED' | 'FULLY_AUTOMATED';
|
||||||
|
automationSystems?: Record<string, unknown>;
|
||||||
|
}): Promise<VerticalFarm> {
|
||||||
|
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<VerticalFarm | null> {
|
||||||
|
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<VerticalFarm> {
|
||||||
|
return prisma.verticalFarm.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update farm status
|
||||||
|
export async function updateFarmStatus(
|
||||||
|
id: string,
|
||||||
|
status: FarmStatus
|
||||||
|
): Promise<VerticalFarm> {
|
||||||
|
return prisma.verticalFarm.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete vertical farm
|
||||||
|
export async function deleteVerticalFarm(id: string): Promise<VerticalFarm> {
|
||||||
|
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<PaginatedResult<VerticalFarm>> {
|
||||||
|
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<VerticalFarm[]> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}): Promise<GrowingZone> {
|
||||||
|
return prisma.growingZone.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
status: 'EMPTY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get growing zone by ID
|
||||||
|
export async function getGrowingZoneById(id: string): Promise<GrowingZone | null> {
|
||||||
|
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<GrowingZone> {
|
||||||
|
return prisma.growingZone.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update zone status
|
||||||
|
export async function updateZoneStatus(
|
||||||
|
id: string,
|
||||||
|
status: ZoneStatus
|
||||||
|
): Promise<GrowingZone> {
|
||||||
|
return prisma.growingZone.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete growing zone
|
||||||
|
export async function deleteGrowingZone(id: string): Promise<GrowingZone> {
|
||||||
|
return prisma.growingZone.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get zones by farm
|
||||||
|
export async function getGrowingZonesByFarm(farmId: string): Promise<GrowingZone[]> {
|
||||||
|
return prisma.growingZone.findMany({
|
||||||
|
where: { farmId },
|
||||||
|
orderBy: [{ level: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update zone environment readings
|
||||||
|
export async function updateZoneEnvironment(
|
||||||
|
id: string,
|
||||||
|
readings: Record<string, unknown>
|
||||||
|
): Promise<GrowingZone> {
|
||||||
|
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<CropBatch> {
|
||||||
|
// 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<CropBatch | null> {
|
||||||
|
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<CropBatch> {
|
||||||
|
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<CropBatch> {
|
||||||
|
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<CropBatch> {
|
||||||
|
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<PaginatedResult<CropBatch>> {
|
||||||
|
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<CropBatch[]> {
|
||||||
|
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<string, unknown>[];
|
||||||
|
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<string, unknown>[];
|
||||||
|
expectedDays: number;
|
||||||
|
expectedYieldGrams: number;
|
||||||
|
expectedYieldPerSqm?: number;
|
||||||
|
requirements?: Record<string, unknown>;
|
||||||
|
source?: 'INTERNAL' | 'COMMUNITY' | 'COMMERCIAL';
|
||||||
|
author?: string;
|
||||||
|
}): Promise<GrowingRecipe> {
|
||||||
|
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<GrowingRecipe | null> {
|
||||||
|
return prisma.growingRecipe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recipes by crop type
|
||||||
|
export async function getGrowingRecipesByCrop(cropType: string): Promise<GrowingRecipe[]> {
|
||||||
|
return prisma.growingRecipe.findMany({
|
||||||
|
where: { cropType: { contains: cropType, mode: 'insensitive' } },
|
||||||
|
orderBy: { rating: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment recipe usage
|
||||||
|
export async function incrementRecipeUsage(id: string): Promise<GrowingRecipe> {
|
||||||
|
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<string, unknown>[];
|
||||||
|
topCropsByRevenue?: Record<string, unknown>[];
|
||||||
|
topCropsByEfficiency?: Record<string, unknown>[];
|
||||||
|
}) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
156
lib/db/index.ts
Normal file
156
lib/db/index.ts
Normal file
|
|
@ -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';
|
||||||
378
lib/db/plants.ts
Normal file
378
lib/db/plants.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
growthMetrics?: Record<string, unknown>;
|
||||||
|
notes?: string;
|
||||||
|
images?: string[];
|
||||||
|
plantsNetId?: string;
|
||||||
|
}): Promise<Plant> {
|
||||||
|
// 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<Plant | null> {
|
||||||
|
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<PlantWithLineage | null> {
|
||||||
|
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<Plant> {
|
||||||
|
return prisma.plant.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update plant status
|
||||||
|
export async function updatePlantStatus(
|
||||||
|
id: string,
|
||||||
|
status: PlantStatus,
|
||||||
|
harvestedDate?: Date
|
||||||
|
): Promise<Plant> {
|
||||||
|
return prisma.plant.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
...(harvestedDate && { harvestedDate }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete plant
|
||||||
|
export async function deletePlant(id: string): Promise<Plant> {
|
||||||
|
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<PaginatedResult<Plant>> {
|
||||||
|
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<Plant[]> {
|
||||||
|
return prisma.plant.findMany({
|
||||||
|
where: { ownerId },
|
||||||
|
orderBy: { registeredAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get plants near location
|
||||||
|
export async function getNearbyPlants(
|
||||||
|
location: LocationFilter,
|
||||||
|
excludeOwnerId?: string
|
||||||
|
): Promise<Array<Plant & { distance: number }>> {
|
||||||
|
// 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<PaginatedResult<Plant>> {
|
||||||
|
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<Plant[]> {
|
||||||
|
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<Plant> {
|
||||||
|
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<Plant> {
|
||||||
|
return prisma.plant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { blockIndex, blockHash },
|
||||||
|
});
|
||||||
|
}
|
||||||
27
lib/db/prisma.ts
Normal file
27
lib/db/prisma.ts
Normal file
|
|
@ -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;
|
||||||
410
lib/db/transport.ts
Normal file
410
lib/db/transport.ts
Normal file
|
|
@ -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<TransportMethod, number> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
plantIds?: string[];
|
||||||
|
seedBatchId?: string;
|
||||||
|
harvestBatchId?: string;
|
||||||
|
cargoWeightKg?: number;
|
||||||
|
}): Promise<TransportEvent> {
|
||||||
|
// 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<TransportEvent | null> {
|
||||||
|
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<TransportEvent> {
|
||||||
|
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<TransportEvent> {
|
||||||
|
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<TransportEvent> {
|
||||||
|
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<PaginatedResult<TransportEvent>> {
|
||||||
|
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<TransportEvent[]> {
|
||||||
|
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<string, { distance: number; carbon: number }> = {};
|
||||||
|
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<string, { count: number; carbon: number }> = {};
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
150
lib/db/types.ts
Normal file
150
lib/db/types.ts
Normal file
|
|
@ -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<T> {
|
||||||
|
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<T>(
|
||||||
|
items: T[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
limit: number
|
||||||
|
): PaginatedResult<T> {
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
hasMore: page < totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
199
lib/db/users.ts
Normal file
199
lib/db/users.ts
Normal file
|
|
@ -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<User> {
|
||||||
|
return prisma.user.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
userType: data.userType || 'CONSUMER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
export async function getUserById(id: string): Promise<User | null> {
|
||||||
|
return prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
export async function getUserByEmail(email: string): Promise<User | null> {
|
||||||
|
return prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by wallet address
|
||||||
|
export async function getUserByWalletAddress(walletAddress: string): Promise<User | null> {
|
||||||
|
return prisma.user.findUnique({
|
||||||
|
where: { walletAddress },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
export async function updateUser(
|
||||||
|
id: string,
|
||||||
|
data: Prisma.UserUpdateInput
|
||||||
|
): Promise<User> {
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
export async function deleteUser(id: string): Promise<User> {
|
||||||
|
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<PaginatedResult<User>> {
|
||||||
|
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<User[]> {
|
||||||
|
return prisma.user.findMany({
|
||||||
|
where: { userType },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
export async function updateLastLogin(id: string): Promise<User> {
|
||||||
|
return prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search users
|
||||||
|
export async function searchUsers(
|
||||||
|
query: string,
|
||||||
|
options: PaginationOptions = {}
|
||||||
|
): Promise<PaginatedResult<User>> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
package.json
10
package.json
|
|
@ -15,9 +15,16 @@
|
||||||
"cy:open": "cypress open",
|
"cy:open": "cypress open",
|
||||||
"cy:run": "cypress run",
|
"cy:run": "cypress run",
|
||||||
"test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open",
|
"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": {
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.7.0",
|
||||||
"@tailwindcss/forms": "^0.4.0",
|
"@tailwindcss/forms": "^0.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.1",
|
"@tailwindcss/typography": "^0.5.1",
|
||||||
"@tanstack/react-query": "^4.0.10",
|
"@tanstack/react-query": "^4.0.10",
|
||||||
|
|
@ -41,6 +48,7 @@
|
||||||
"eslint-config-next": "^12.0.10",
|
"eslint-config-next": "^12.0.10",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"postcss": "^8.4.5",
|
"postcss": "^8.4.5",
|
||||||
|
"prisma": "^5.7.0",
|
||||||
"tailwindcss": "^3.0.15",
|
"tailwindcss": "^3.0.15",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
"typescript": "^4.5.5"
|
"typescript": "^4.5.5"
|
||||||
|
|
|
||||||
1084
prisma/schema.prisma
Normal file
1084
prisma/schema.prisma
Normal file
File diff suppressed because it is too large
Load diff
835
prisma/seed.ts
Normal file
835
prisma/seed.ts
Normal file
|
|
@ -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();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue