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