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.
410 lines
11 KiB
TypeScript
410 lines
11 KiB
TypeScript
/**
|
|
* 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,
|
|
},
|
|
});
|
|
}
|