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
|
||||
|
||||
# ===========================================
|
||||
# DATABASE CONFIGURATION (Required for Agent 2)
|
||||
# ===========================================
|
||||
|
||||
# PostgreSQL connection string
|
||||
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/localgreenchain?schema=public"
|
||||
|
||||
# ===========================================
|
||||
# EXTERNAL SERVICES
|
||||
# ===========================================
|
||||
|
||||
# Plants.net API (optional)
|
||||
PLANTS_NET_API_KEY=your_api_key_here
|
||||
|
||||
|
|
|
|||
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
|
||||
* Singleton to manage the global plant blockchain instance
|
||||
*
|
||||
* Supports two modes:
|
||||
* 1. File-based (legacy): Uses JSON file storage
|
||||
* 2. Database-backed: Uses PostgreSQL via Prisma (recommended)
|
||||
*/
|
||||
|
||||
import { PlantChain } from './PlantChain';
|
||||
import { PlantChainDB, getPlantChain as getDBPlantChain } from './PlantChainDB';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const BLOCKCHAIN_FILE = path.join(process.cwd(), 'data', 'plantchain.json');
|
||||
|
||||
// Flag to determine storage mode
|
||||
const USE_DATABASE = process.env.DATABASE_URL ? true : false;
|
||||
|
||||
class BlockchainManager {
|
||||
private static instance: BlockchainManager;
|
||||
private plantChain: PlantChain;
|
||||
|
|
@ -104,3 +112,21 @@ export function getBlockchain(): PlantChain {
|
|||
export function saveBlockchain(): void {
|
||||
BlockchainManager.getInstance().saveBlockchain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database-backed blockchain instance
|
||||
* Use this for production applications with PostgreSQL
|
||||
*/
|
||||
export async function getBlockchainDB(): Promise<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:run": "cypress run",
|
||||
"test:e2e": "start-server-and-test 'bun run preview' http://localhost:3001 cy:open",
|
||||
"test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run"
|
||||
"test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:prod": "prisma migrate deploy",
|
||||
"db:seed": "bun run prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@tailwindcss/typography": "^0.5.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
|
|
@ -41,6 +48,7 @@
|
|||
"eslint-config-next": "^12.0.10",
|
||||
"jest": "^29.5.0",
|
||||
"postcss": "^8.4.5",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^4.5.5"
|
||||
|
|
|
|||
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