localgreenchain/lib/db/demand.ts
Claude 3d2ccdc29a
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.
2025-11-23 03:56:40 +00:00

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);
}