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.
597 lines
16 KiB
TypeScript
597 lines
16 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|